Repository: NeuralMMO/environment (language: python)

Issue:
Ijcai competition



Discussion / hints:

Fix patch:
diff --git a/nmmo/__init__.py b/nmmo/__init__.py
index d00f0456a..1771ab53a 100644
--- a/nmmo/__init__.py
+++ b/nmmo/__init__.py
@@ -9,7 +9,6 @@
 from .core import config, agent
 from .core.agent import Agent
 from .core.env import Env
-from .systems.achievement import Task
 from .core.terrain import MapGenerator, Terrain
 
 MOTD = rf'''      ___           ___           ___           ___
@@ -26,7 +25,7 @@
 
 __all__ = ['Env', 'config', 'agent', 'Agent', 'MapGenerator', 'Terrain',
         'action', 'Action', 'material', 'spawn',
-        'Task', 'Overlay', 'OverlayRegistry']
+        'Overlay', 'OverlayRegistry']
 
 try:
   __all__.append('OpenSkillRating')
diff --git a/nmmo/core/config.py b/nmmo/core/config.py
index eca6181f6..cae9b83da 100644
--- a/nmmo/core/config.py
+++ b/nmmo/core/config.py
@@ -154,9 +154,6 @@ def game_system_enabled(self, name) -> bool:
   PLAYERS                      = [Agent]
   '''Player classes from which to spawn'''
 
-  TASKS                        = []
-  '''Tasks for which to compute rewards'''
-
   ############################################################################
   ### Emulation Parameters
 
diff --git a/nmmo/core/env.py b/nmmo/core/env.py
index d7f0b4491..f43a370a7 100644
--- a/nmmo/core/env.py
+++ b/nmmo/core/env.py
@@ -1,6 +1,7 @@
 import functools
 import random
-from typing import Any, Dict, List
+import copy
+from typing import Any, Dict, List, Optional, Union, Tuple
 from ordered_set import OrderedSet
 
 import gym
@@ -14,13 +15,15 @@
 from nmmo.entity.entity import Entity
 from nmmo.systems.item import Item
 from nmmo.core import realm
-
+from nmmo.task.game_state import GameStateGenerator
+from nmmo.task.task_api import Task
+from nmmo.task.scenario import default_task
 from scripted.baselines import Scripted
 
-
 class Env(ParallelEnv):
   # Environment wrapper for Neural MMO using the Parallel PettingZoo API
 
+  #pylint: disable=no-value-for-parameter
   def __init__(self,
     config: Default = nmmo.config.Default(), seed=None):
     self._init_random(seed)
@@ -35,6 +38,18 @@ def __init__(self,
     self._dead_agents = OrderedSet()
     self.scripted_agents = OrderedSet()
 
+    self._gamestate_generator = GameStateGenerator(self.realm, self.config)
+    self.game_state = None
+    # Default task: rewards 1 each turn agent is alive
+    self.tasks: List[Tuple[Task,float]] = None
+    self._task_encoding = None
+    self._task_embedding_size = -1
+    t = default_task(self.possible_agents)
+    self.change_task(t,
+                     embedding_size=self._task_embedding_size,
+                     task_encoding=self._task_encoding,
+                     reset=False)
+
   # pylint: disable=method-cache-max-size-none
   @functools.lru_cache(maxsize=None)
   def observation_space(self, agent: int):
@@ -60,7 +75,7 @@ def box(rows, cols):
       "Tick": gym.spaces.Discrete(1),
       "AgentId": gym.spaces.Discrete(1),
       "Tile": box(self.config.MAP_N_OBS, Tile.State.num_attributes),
-      "Entity": box(self.config.PLAYER_N_OBS, Entity.State.num_attributes)
+      "Entity": box(self.config.PLAYER_N_OBS, Entity.State.num_attributes),
     }
 
     if self.config.ITEM_SYSTEM_ENABLED:
@@ -72,6 +87,12 @@ def box(rows, cols):
     if self.config.PROVIDE_ACTION_TARGETS:
       obs_space['ActionTargets'] = self.action_space(None)
 
+    if self._task_encoding:
+      obs_space['Task'] = gym.spaces.Box(
+          low=-2**20, high=2**20,
+          shape=(self._task_embedding_size,),
+          dtype=np.float32)
+
     return gym.spaces.Dict(obs_space)
 
   def _init_random(self, seed):
@@ -109,6 +130,28 @@ def action_space(self, agent):
   ############################################################################
   # Core API
 
+  def change_task(self,
+                  new_tasks: List[Union[Tuple[Task, float], Task]],
+                  task_encoding: Optional[Dict[int, np.ndarray]] = None,
+                  embedding_size: int=16,
+                  reset: bool=True,
+                  map_id=None,
+                  seed=None,
+                  options=None):
+    """ Changes the task given to each agent
+
+    Args:
+      new_task: The task to complete and calculate rewards
+      task_encoding: A mapping from eid to encoded task
+      embedding_size: The size of each embedding
+      reset: Resets the environment
+    """
+    self._tasks = [t if isinstance(t, Tuple) else (t,1) for t in new_tasks]
+    self._task_encoding = task_encoding
+    self._task_embedding_size = embedding_size
+    if reset:
+      self.reset(map_id=map_id, seed=seed, options=options)
+
   # TODO: This doesn't conform to the PettingZoo API
   # pylint: disable=arguments-renamed
   def reset(self, map_id=None, seed=None, options=None):
@@ -142,9 +185,16 @@ def reset(self, map_id=None, seed=None, options=None):
       if isinstance(ent.agent, Scripted):
         self.scripted_agents.add(eid)
 
+    self.tasks = copy.deepcopy(self._tasks)
     self.obs = self._compute_observations()
+    self._gamestate_generator = GameStateGenerator(self.realm, self.config)
 
-    return {a: o.to_gym() for a,o in self.obs.items()}
+    gym_obs = {}
+    for a, o in self.obs.items():
+      gym_obs[a] = o.to_gym()
+      if self._task_encoding:
+        gym_obs[a]['Task'] = self._encode_goal().get(a,np.zeros(self._task_embedding_size))
+    return gym_obs
 
   def step(self, actions: Dict[int, Dict[str, Dict[str, Any]]]):
     '''Simulates one game tick or timestep
@@ -239,7 +289,6 @@ def step(self, actions: Dict[int, Dict[str, Dict[str, Any]]]):
           Provided for conformity with PettingZoo
     '''
     assert self.obs is not None, 'step() called before reset'
-
     # Add in scripted agents' actions, if any
     if self.scripted_agents:
       actions = self._compute_scripted_agent_actions(actions)
@@ -247,10 +296,8 @@ def step(self, actions: Dict[int, Dict[str, Dict[str, Any]]]):
     # Drop invalid actions of BOTH neural and scripted agents
     #   we don't need _deserialize_scripted_actions() anymore
     actions = self._validate_actions(actions)
-
     # Execute actions
     self.realm.step(actions)
-
     dones = {}
     for eid in self.possible_agents:
       if eid not in self._dead_agents and (
@@ -262,7 +309,11 @@ def step(self, actions: Dict[int, Dict[str, Dict[str, Any]]]):
 
     # Store the observations, since actions reference them
     self.obs = self._compute_observations()
-    gym_obs = {a: o.to_gym() for a,o in self.obs.items()}
+    gym_obs = {}
+    for a, o in self.obs.items():
+      gym_obs[a] = o.to_gym()
+      if self._task_encoding:
+        gym_obs[a]['Task'] = self._encode_goal()[a]
 
     rewards, infos = self._compute_rewards(self.obs.keys(), dones)
 
@@ -332,6 +383,7 @@ def _compute_observations(self):
         obs: Dictionary of observations for each agent
         obs[agent_id] = {
           "Entity": [e1, e2, ...],
+          "Task": [encoded_task],
           "Tile": [t1, t2, ...],
           "Inventory": [i1, i2, ...],
           "Market": [m1, m2, ...],
@@ -364,12 +416,17 @@ def _compute_observations(self):
 
       inventory = Item.Query.owned_by(self.realm.datastore, agent_id)
 
-      obs[agent_id] = Observation(
-        self.config, self.realm.tick,
-        agent_id, visible_tiles, visible_entities, inventory, market)
-
+      obs[agent_id] = Observation(self.config,
+                                  self.realm.tick,
+                                  agent_id,
+                                  visible_tiles,
+                                  visible_entities,
+                                  inventory, market)
     return obs
 
+  def _encode_goal(self):
+    return self._task_encoding
+
   def _compute_rewards(self, agents: List[AgentID], dones: Dict[AgentID, bool]):
     '''Computes the reward for the specified agent
 
@@ -385,21 +442,31 @@ def _compute_rewards(self, agents: List[AgentID], dones: Dict[AgentID, bool]):
           The reward for the actions on the previous timestep of the
           entity identified by ent_id.
     '''
+    # Initialization
+    self.game_state = self._gamestate_generator.generate(self.realm, self.obs)
     infos = {}
-    rewards = { eid: -1 for eid in dones }
-
-    for agent_id in agents:
-      infos[agent_id] = {}
-      agent = self.realm.players.get(agent_id)
-      assert agent is not None, f'Agent {agent_id} not found'
-
-      if agent.diary is not None:
-        rewards[agent_id] = sum(agent.diary.rewards.values())
-        infos[agent_id].update(agent.diary.rewards)
+    for eid in agents:
+      infos[eid] = {}
+      infos[eid]['task'] = {}
+    rewards = {eid: 0 for eid in agents}
+
+    # Compute Rewards and infos
+    for task, weight in self.tasks:
+      task_rewards, task_infos = task.compute_rewards(self.game_state)
+      for eid, reward in task_rewards.items():
+        # Rewards, weighted
+        rewards[eid] = rewards.get(eid,0) + reward * weight
+        # Infos
+        for eid, info in task_infos.items():
+          if eid in infos:
+            infos[eid]['task'] = {**infos[eid]['task'], **info}
+
+    # Remove rewards for dead agents (?)
+    for eid in dones:
+      rewards[eid] = 0
 
     return rewards, infos
 
-
   ############################################################################
   # PettingZoo API
   ############################################################################
diff --git a/nmmo/core/log_helper.py b/nmmo/core/log_helper.py
index bb118f049..8f6a318c6 100644
--- a/nmmo/core/log_helper.py
+++ b/nmmo/core/log_helper.py
@@ -80,13 +80,6 @@ def _register_player_stat(self, name: str, func: callable):
 
   def _register_player_stats(self):
     self._register_player_stat('Basic/TimeAlive', lambda player: player.history.time_alive.val)
-
-    if self.config.TASKS:
-      self._register_player_stat('Task/Completed', lambda player: player.diary.completed)
-      self._register_player_stat('Task/Reward' , lambda player: player.diary.cumulative_reward)
-    else:
-      self._register_player_stat('Task/Completed', lambda player: player.history.time_alive.val)
-
     # Skills
     if self.config.PROGRESSION_SYSTEM_ENABLED:
       if self.config.COMBAT_SYSTEM_ENABLED:
@@ -137,13 +130,6 @@ def _player_stats(self, player: Agent) -> Dict[str, float]:
     for key, stat_func in self._player_stats_funcs.items():
       stats[f'{key}_{policy}'] = stat_func(player)
 
-    stats['Task_Reward'] = player.history.time_alive.val
-
-    # If diary is enabled, log task and achievement stats
-    if player.diary:
-      stats['Task_Reward'] = player.diary.cumulative_reward
-
-      for achievement in player.diary.achievements:
-        stats["Achievement_{achievement.name}"] = float(achievement.completed)
+    stats['Time_Alive'] = player.history.time_alive.val
 
     return stats
diff --git a/nmmo/core/observation.py b/nmmo/core/observation.py
index 2697bd140..e3c4ea6c5 100644
--- a/nmmo/core/observation.py
+++ b/nmmo/core/observation.py
@@ -123,7 +123,7 @@ def to_gym(self):
         self.entities.values, np.zeros((
           self.config.PLAYER_N_OBS - self.entities.values.shape[0],
           self.entities.values.shape[1]))
-      ]),
+      ])
     }
 
     if self.config.ITEM_SYSTEM_ENABLED:
diff --git a/nmmo/core/realm.py b/nmmo/core/realm.py
index e967a147c..f01db458c 100644
--- a/nmmo/core/realm.py
+++ b/nmmo/core/realm.py
@@ -164,6 +164,7 @@ def step(self, actions):
     #  - 60: Move
     #  - 70: Sell - to guarantee the listed items are available to buy
     #  - 99: Comm
+
     for priority in sorted(merged):
       # TODO: we should be randomizing these, otherwise the lower ID agents
       # will always go first. --> ONLY SHUFFLE BUY
@@ -176,7 +177,6 @@ def step(self, actions):
         ent = self.entity(ent_id)
         if ent.alive:
           atn.call(self, ent, *args)
-
     dead = self.players.cull()
     self.npcs.cull()
 
diff --git a/nmmo/core/terrain.py b/nmmo/core/terrain.py
index e63fd5a9f..b90750884 100644
--- a/nmmo/core/terrain.py
+++ b/nmmo/core/terrain.py
@@ -5,7 +5,7 @@
 
 import numpy as np
 import vec_noise
-from imageio import imread, imsave
+from imageio.v2 import imread, imsave
 from scipy import stats
 
 from nmmo import material
@@ -237,18 +237,13 @@ def generate_all_maps(self):
     path_maps = os.path.join(config.PATH_CWD, config.PATH_MAPS)
     os.makedirs(path_maps, exist_ok=True)
 
-    if not config.MAP_FORCE_GENERATION and os.listdir(path_maps):
-      # check if the folder has all the required maps
-      all_maps_exist = True
-      for idx in range(config.MAP_N, -1, -1):
-        map_file = path_maps + '/map' + str(idx+1) + '/map.npy'
-        if not os.path.exists(map_file):
-          # override MAP_FORCE_GENERATION = FALSE and generate maps
-          all_maps_exist = False
-          break
-
-      # do not generate maps if all maps exist
-      if all_maps_exist:
+    existing_maps = set(map_dir + '/map.npy' for map_dir in os.listdir(path_maps))
+    if not config.MAP_FORCE_GENERATION and existing_maps:
+      required_maps = {
+        f'map{idx}/map.npy' for idx in range(1, config.MAP_N+1)
+      }
+      missing = required_maps - existing_maps
+      if not missing:
         return
 
     if __debug__:
diff --git a/nmmo/entity/entity_manager.py b/nmmo/entity/entity_manager.py
index 7315097bc..02c749774 100644
--- a/nmmo/entity/entity_manager.py
+++ b/nmmo/entity/entity_manager.py
@@ -117,6 +117,9 @@ def cull(self):
     for entity in super().cull().values():
       self.spawn_dangers.append(entity.spawn_danger)
 
+    # refill npcs to target config.NPC_N, within config.NPC_SPAWN_ATTEMPTS
+    self.spawn()
+
   def actions(self, realm):
     actions = {}
     for idx, entity in self.entities.items():
@@ -143,7 +146,7 @@ def spawn_individual(self, r, c, idx):
 
   def spawn(self):
     idx = 0
-    for r, c in spawn.spawn_concurrent(self.config):
+    for r, c in spawn.spawn_concurrent(self.config, self.realm):
       idx += 1
 
       if idx in self.entities:
diff --git a/nmmo/entity/npc.py b/nmmo/entity/npc.py
index 2e431644f..9f4ebae07 100644
--- a/nmmo/entity/npc.py
+++ b/nmmo/entity/npc.py
@@ -81,8 +81,10 @@ def receive_damage(self, source, dmg):
     #   because source cannot take it if the inventory is full
     #   Also, destroy the remaining items if the source cannot take those
     for item in self.droptable.roll(self.realm, self.attack_level):
-      if source.inventory.space:
+      if source.is_player and source.inventory.space:
         source.inventory.receive(item)
+      else:
+        item.destroy()
 
     return False
 
@@ -90,6 +92,10 @@ def receive_damage(self, source, dmg):
   def spawn(realm, pos, iden):
     config = realm.config
 
+    # check the position
+    if realm.map.tiles[pos].impassible:
+      return None
+
     # Select AI Policy
     danger = combat.danger(config, pos)
     if danger >= config.NPC_SPAWN_AGGRESSIVE:
diff --git a/nmmo/entity/player.py b/nmmo/entity/player.py
index cbb900295..779d65d62 100644
--- a/nmmo/entity/player.py
+++ b/nmmo/entity/player.py
@@ -1,5 +1,4 @@
 from nmmo.systems.skill import Skills
-from nmmo.systems.achievement import Diary
 from nmmo.entity import entity
 
 # pylint: disable=no-member
@@ -30,11 +29,6 @@ def __init__(self, realm, pos, agent):
     if realm.config.EXCHANGE_SYSTEM_ENABLED:
       self.gold.update(1)
 
-    self.diary  = None
-    tasks = realm.config.TASKS
-    if tasks:
-      self.diary = Diary(self, tasks)
-
   @property
   def serial(self):
     return self.ent_id
@@ -67,7 +61,7 @@ def receive_damage(self, source, dmg):
       return False
 
     # starting from here, source receive gold & inventory items
-    if self.config.EXCHANGE_SYSTEM_ENABLED:
+    if self.config.EXCHANGE_SYSTEM_ENABLED and source is not None:
       source.gold.increment(self.gold.val)
       self.gold.update(0)
 
@@ -78,7 +72,8 @@ def receive_damage(self, source, dmg):
       self.inventory.remove(item)
 
       # if source doesn't have space, inventory.receive() destroys the item
-      source.inventory.receive(item)
+      if source.is_player:
+        source.inventory.receive(item)
 
     # CHECK ME: this is an empty function. do we still need this?
     self.skills.receive_damage(dmg)
@@ -131,6 +126,3 @@ def update(self, realm, actions):
 
     self.resources.update()
     self.skills.update()
-
-    if self.diary:
-      self.diary.update(realm)
diff --git a/nmmo/io/action.py b/nmmo/io/action.py
index 434c0e5c4..c8626d798 100644
--- a/nmmo/io/action.py
+++ b/nmmo/io/action.py
@@ -3,7 +3,6 @@
 
 from enum import Enum, auto
 from ordered_set import OrderedSet
-
 import numpy as np
 
 from nmmo.lib import utils
@@ -11,7 +10,6 @@
 from nmmo.systems.item import Item, Stack
 from nmmo.lib.log import EventCode
 
-
 class NodeType(Enum):
   #Tree edges
   STATIC = auto()    #Traverses all edges without decisions
@@ -203,6 +201,7 @@ class West(Node):
 class Stay(Node):
   delta = (0, 0)
 
+
 class Attack(Node):
   priority = 50
   nodeType = NodeType.SELECTION
diff --git a/nmmo/lib/spawn.py b/nmmo/lib/spawn.py
index e834e8910..b9c0c8551 100644
--- a/nmmo/lib/spawn.py
+++ b/nmmo/lib/spawn.py
@@ -39,7 +39,7 @@ def spawn_continuous(config):
   return (r, c)
 
 
-def spawn_concurrent(config):
+def spawn_concurrent(config, realm):
   '''Generates spawn positions for new agents
 
   Evenly spaces agents around the borders
@@ -84,6 +84,9 @@ def spawn_concurrent(config):
   sides += list(zip(inc[::-1], lows))
   np.random.shuffle(sides)
 
+  # filter out invalid spawn positions
+  sides = [pos for pos in sides if not realm.map.tiles[pos].impassible]
+
   if team_n > 1:
     # Space across and within teams
     spawn_positions = []
diff --git a/nmmo/lib/task.py b/nmmo/lib/task.py
deleted file mode 100644
index e25c64518..000000000
--- a/nmmo/lib/task.py
+++ /dev/null
@@ -1,263 +0,0 @@
-import json
-import random
-from typing import List
-
-
-# pylint: disable=abstract-method, super-init-not-called
-
-class Task():
-  def completed(self, realm, entity) -> bool:
-    raise NotImplementedError
-
-  def description(self) -> List:
-    return self.__class__.__name__
-
-  def to_string(self) -> str:
-    return json.dumps(self.description())
-
-###############################################################
-
-class TaskTarget():
-  def __init__(self, name: str, agents: List[str]) -> None:
-    self._name = name
-    self._agents = agents
-
-  def agents(self) ->  List[int]:
-    return self._agents
-
-  def description(self) -> List:
-    return self._name
-
-  def member(self, member):
-    assert member < len(self._agents)
-    return TaskTarget(f"{self.description()}.{member}", [self._agents[member]])
-
-class TargetTask(Task):
-  def __init__(self, target: TaskTarget) -> None:
-    self._target = target
-
-  def description(self) -> List:
-    return [super().description(), self._target.description()]
-
-  def completed(self, realm, entity) -> bool:
-    raise NotImplementedError
-
-###############################################################
-
-class TeamHelper():
-  def __init__(self, agents: List[int], num_teams: int) -> None:
-    assert len(agents) % num_teams == 0
-    self.team_size = len(agents) // num_teams
-    self._teams = [
-      list(agents[i * self.team_size : (i+1) * self.team_size])
-      for i in range(num_teams)
-    ]
-    self._agent_to_team = {a: tid for tid, t in enumerate(self._teams) for a in t}
-
-  def own_team(self, agent_id: int) -> TaskTarget:
-    return TaskTarget("Team.Self", self._teams[self._agent_to_team[agent_id]])
-
-  def left_team(self, agent_id: int) -> TaskTarget:
-    return TaskTarget("Team.Left", self._teams[
-      (self._agent_to_team[agent_id] -1) % len(self._teams)
-    ])
-
-  def right_team(self, agent_id: int) -> TaskTarget:
-    return TaskTarget("Team.Right", self._teams[
-      (self._agent_to_team[agent_id] + 1) % len(self._teams)
-    ])
-
-  def all(self) -> TaskTarget:
-    return TaskTarget("All", list(self._agent_to_team.keys()))
-
-###############################################################
-
-class AND(Task):
-  def __init__(self, *tasks: Task) -> None:
-    super().__init__()
-    assert len(tasks) > 0
-    self._tasks = tasks
-
-  def completed(self, realm, entity) -> bool:
-    return all(t.completed(realm, entity) for t in self._tasks)
-
-  def description(self) -> List:
-    return ["AND"] + [t.description() for t in self._tasks]
-
-class OR(Task):
-  def __init__(self, *tasks: Task) -> None:
-    super().__init__()
-    assert len(tasks) > 0
-    self._tasks = tasks
-
-  def completed(self, realm, entity) -> bool:
-    return any(t.completed(realm, entity) for t in self._tasks)
-
-  def description(self) -> List:
-    return ["OR"] + [t.description() for t in self._tasks]
-
-class NOT(Task):
-  def __init__(self, task: Task) -> None:
-    super().__init__()
-    self._task = task
-
-  def completed(self, realm, entity) -> bool:
-    return not self._task.completed(realm, entity)
-
-  def description(self) -> List:
-    return ["NOT", self._task.description()]
-
-###############################################################
-
-class InflictDamage(TargetTask):
-  def __init__(self, target: TaskTarget, damage_type: int, quantity: int):
-    super().__init__(target)
-    self._damage_type = damage_type
-    self._quantity = quantity
-
-  def completed(self, realm, entity) -> bool:
-    # TODO(daveey) damage_type is ignored, needs to be added to entity.history
-    return sum(
-      realm.players[a].history.damage_inflicted for a in self._target.agents()
-    ) >= self._quantity
-
-  def description(self) -> List:
-    return super().description() + [self._damage_type, self._quantity]
-
-class Defend(TargetTask):
-  def __init__(self, target, num_steps) -> None:
-    super().__init__(target)
-    self._num_steps = num_steps
-
-  def completed(self, realm, entity) -> bool:
-    # TODO(daveey) need a way to specify time horizon
-    return realm.tick >= self._num_steps and all(
-      realm.players[a].alive for a in self._target.agents()
-    )
-
-  def description(self) -> List:
-    return super().description() + [self._num_steps]
-
-class Inflict(TargetTask):
-  def __init__(self, target: TaskTarget, damage_type, quantity: int):
-    '''
-    target: The team that is completing the task. Any agent may complete
-    damage_type: Can use skills.Melee/Range/Mage
-    quantity: Minimum damage to inflict in a single hit
-    '''
-
-class Defeat(TargetTask):
-  def __init__(self, target: TaskTarget, entity_type, level: int):
-    '''
-    target: The team that is completing the task. Any agent may complete
-    entity type: entity.Player or entity.NPC
-    level: minimum target level to defeat
-      '''
-
-class Achieve(TargetTask):
-  def __init__(self, target: TaskTarget, skill, level: int):
-    '''
-    target: The team that is completing the task. Any agent may complete.
-    skill: systems.skill to advance
-    level: level to reach
-    '''
-
-class Harvest(TargetTask):
-  def __init__(self, target: TaskTarget, resource, level: int):
-    '''
-    target: The team that is completing the task. Any agent may complete
-    resource: lib.material to harvest
-    level: minimum material level to harvest
-    '''
-
-class Equip(Task):
-  def __init__(self, target: TaskTarget, item, level: int):
-    '''
-    target: The team that is completing the task. Any agent may complete.
-    item: systems.item to equip
-    level: Minimum level of that item
-    '''
-
-class Hoard(Task):
-  def __init__(self, target: TaskTarget, gold):
-    '''
-    target: The team that is completing the task. Completed across the team
-    gold: reach this amount of gold held at one time (inventory.gold sum over team)
-    '''
-
-class Group(Task):
-  def __init__(self, target: TaskTarget, num_teammates: int, distance: int):
-    '''
-    target: The team that is completing the task. Completed across the team
-    num_teammates: Number of teammates to group together
-    distance: Max distance to nearest teammate
-    '''
-
-class Spread(Task):
-  def __init__(self, target: TaskTarget, num_teammates: int, distance: int):
-    '''
-    target: The team that is completing the task. Completed across the team
-    num_teammates: Number of teammates to group together
-    distance: Min distance to nearest teammate
-    '''
-
-class Eliminate(Task):
-  def __init__(self, target: TaskTarget, opponent_team):
-    '''
-    target: The team that is completing the task. Completed across the team
-    opponent_team: left/right/any team to be eliminated (all agents defeated)
-    '''
-
-###############################################################
-
-class TaskSampler():
-  def __init__(self) -> None:
-    self._task_specs = []
-    self._task_spec_weights = []
-
-  def add_task_spec(self, task_class, param_space = None, weight: float = 1):
-    self._task_specs.append((task_class, param_space or []))
-    self._task_spec_weights.append(weight)
-
-  def sample(self,
-             min_clauses: int = 1,
-             max_clauses: int = 1,
-             min_clause_size: int = 1,
-             max_clause_size: int = 1,
-             not_p: float = 0.0) -> Task:
-
-    clauses = []
-    for _ in range(0, random.randint(min_clauses, max_clauses)):
-      task_specs = random.choices(
-        self._task_specs,
-        weights = self._task_spec_weights,
-        k = random.randint(min_clause_size, max_clause_size)
-      )
-      tasks = []
-      for task_class, task_param_space in task_specs:
-        task = task_class(*[random.choice(tp) for tp in task_param_space])
-        if random.random() < not_p:
-          task = NOT(task)
-        tasks.append(task)
-
-      if len(tasks) == 1:
-        clauses.append(tasks[0])
-      else:
-        clauses.append(AND(*tasks))
-
-    if len(clauses) == 1:
-      return clauses[0]
-
-    return OR(*clauses)
-
-  @staticmethod
-  def create_default_task_sampler(team_helper: TeamHelper, agent_id: int):
-    neighbors = [team_helper.left_team(agent_id), team_helper.right_team(agent_id)]
-    own_team = team_helper.own_team(agent_id)
-    team_mates = [own_team.member(m) for m in range(team_helper.team_size)]
-    sampler = TaskSampler()
-
-    sampler.add_task_spec(InflictDamage, [neighbors + [own_team], [0, 1, 2], [0, 100, 1000]])
-    sampler.add_task_spec(Defend, [team_mates, [512, 1024]])
-
-    return sampler
diff --git a/nmmo/systems/achievement.py b/nmmo/systems/achievement.py
deleted file mode 100644
index 08eddfc4c..000000000
--- a/nmmo/systems/achievement.py
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-from typing import List
-from nmmo.lib.task import Task
-
-class Achievement:
-  def __init__(self, task: Task, reward: float):
-    self.completed = False
-    self.task = task
-    self.reward = reward
-
-  @property
-  def name(self):
-    return self.task.to_string()
-
-  def update(self, realm, entity):
-    if self.completed:
-      return 0
-
-    if self.task.completed(realm, entity):
-      self.completed = True
-      return self.reward
-
-    return 0
-
-class Diary:
-  def __init__(self, agent, achievements: List[Achievement]):
-    self.agent = agent
-    self.achievements = achievements
-    self.rewards = {}
-
-  @property
-  def completed(self):
-    return sum(a.completed for a in self.achievements)
-
-  @property
-  def cumulative_reward(self):
-    return sum(a.reward * a.completed for a in self.achievements)
-
-  def update(self, realm):
-    self.rewards = { a.name: a.update(realm, self.agent) for a in self.achievements }
diff --git a/nmmo/systems/inventory.py b/nmmo/systems/inventory.py
index 0b21226ae..6e6f19bd3 100644
--- a/nmmo/systems/inventory.py
+++ b/nmmo/systems/inventory.py
@@ -99,11 +99,11 @@ def __init__(self, realm, entity):
     self.equipment   = Equipment()
     self.capacity = 0
 
-    if config.ITEM_SYSTEM_ENABLED:
+    if config.ITEM_SYSTEM_ENABLED and entity.is_player:
       self.capacity         = config.ITEM_INVENTORY_CAPACITY
 
-      self._item_stacks: Dict[Tuple, Item.Stack] = {}
-      self.items: OrderedSet[Item.Item] = OrderedSet([])
+    self._item_stacks: Dict[Tuple, Item.Stack] = {}
+    self.items: OrderedSet[Item.Item] = OrderedSet([])
 
   @property
   def space(self):
diff --git a/nmmo/systems/item.py b/nmmo/systems/item.py
index 68079e082..8aa328563 100644
--- a/nmmo/systems/item.py
+++ b/nmmo/systems/item.py
@@ -5,10 +5,6 @@
 from types import SimpleNamespace
 from typing import Dict
 
-from numpy import real
-
-from nmmo.lib.colors import Tier
-
 from nmmo.datastore.serialized import SerializedState
 from nmmo.lib.colors import Tier
 from nmmo.lib.log import EventCode
diff --git a/nmmo/task/__init__.py b/nmmo/task/__init__.py
new file mode 100644
index 000000000..4bd5626b7
--- /dev/null
+++ b/nmmo/task/__init__.py
@@ -0,0 +1,4 @@
+from .game_state import *
+from .task_api import *
+from .scenario import *
+from .team_helper import *
diff --git a/nmmo/task/base_predicates.py b/nmmo/task/base_predicates.py
new file mode 100644
index 000000000..828029f2d
--- /dev/null
+++ b/nmmo/task/base_predicates.py
@@ -0,0 +1,286 @@
+#pylint: disable=invalid-name, unused-argument, no-value-for-parameter
+from __future__ import annotations
+import numpy as np
+from numpy import count_nonzero as count
+
+from nmmo.task.task_api import OR, define_predicate
+from nmmo.task.group import Group
+from nmmo.task.game_state import GameState
+from nmmo.task import constraint
+from nmmo.systems import skill as nmmo_skill
+from nmmo.systems.skill import Skill
+from nmmo.systems.item import Item
+from nmmo.lib.material import Material
+from nmmo.lib import utils
+
+@define_predicate
+def Success(gs: GameState,
+            subject: Group):
+  ''' Returns True. For debugging.
+  '''
+  return True
+
+@define_predicate
+def TickGE(gs: GameState,
+           subject: Group               = constraint.TEAM_GROUPS,
+           num_tick: int                = constraint.ScalarConstraint()):
+  """True if the current tick is greater than or equal to the specified num_tick.
+  Is progress counter.
+  """
+  return gs.current_tick / num_tick
+
+@define_predicate
+def CanSeeTile(gs: GameState,
+               subject: Group           = constraint.TEAM_GROUPS,
+               tile_type: type[Material]= constraint.MATERIAL_CONSTRAINT):
+  """ True if any agent in subject can see a tile of tile_type
+  """
+  return any(tile_type.index in t for t in subject.obs.tile.material_id)
+
+@define_predicate
+def StayAlive(gs: GameState,
+              subject: Group            = constraint.TEAM_GROUPS):
+  """True if all subjects are alive.
+  """
+  return count(subject.health > 0) == len(subject)
+
+@define_predicate
+def AllDead(gs: GameState,
+            subject: Group              = constraint.TEAM_GROUPS):
+  """True if all subjects are dead.
+  """
+  return 1.0 - count(subject.health) / len(subject)
+
+@define_predicate
+def OccupyTile(gs: GameState,
+               subject: Group,
+               row: int                  = constraint.COORDINATE_CONSTRAINT,
+               col: int                  = constraint.COORDINATE_CONSTRAINT):
+  """True if any subject agent is on the desginated tile.
+  """
+  return np.any((subject.row == row) & (subject.col == col))
+
+@define_predicate
+def AllMembersWithinRange(gs: GameState,
+                          subject: Group = constraint.TEAM_GROUPS,
+                          dist: int      = constraint.COORDINATE_CONSTRAINT):
+  """True if the max l-inf distance of teammates is
+         less than or equal to dist
+  """
+  current_dist = max(subject.row.max()-subject.row.min(),
+      subject.col.max()-subject.col.min())
+  if current_dist <= 0:
+    return 1.0
+  return dist / current_dist
+
+@define_predicate
+def CanSeeAgent(gs: GameState,
+                subject: Group             = constraint.TEAM_GROUPS,
+                target: int                = constraint.AGENT_NUMBER_CONSTRAINT):
+  """True if obj_agent is present in the subjects' entities obs.
+  """
+  return any(target in e.ids for e in subject.obs.entities)
+
+@define_predicate
+def CanSeeGroup(gs: GameState,
+                subject: Group              = constraint.TEAM_GROUPS,
+                target: Group               = constraint.TEAM_GROUPS):
+  """ Returns True if subject can see any of target
+  """
+  return OR(*(CanSeeAgent(subject, agent) for agent in target.agents))
+
+@define_predicate
+def DistanceTraveled(gs: GameState,
+                     subject: Group         = constraint.TEAM_GROUPS,
+                     dist: int              = constraint.ScalarConstraint()):
+  """True if the summed l-inf distance between each agent's current pos and spawn pos
+        is greater than or equal to the specified _dist.
+  """
+  if not any(subject.health > 0):
+    return False
+  r = subject.row
+  c = subject.col
+  dists = utils.linf(list(zip(r,c)),[gs.spawn_pos[id_] for id_ in subject.entity.id])
+  return dists.sum() / dist
+
+@define_predicate
+def AttainSkill(gs: GameState,
+                subject: Group              = constraint.TEAM_GROUPS,
+                skill: Skill                = constraint.SKILL_CONSTRAINT,
+                level: int                  = constraint.PROGRESSION_CONSTRAINT,
+                num_agent: int              = constraint.AGENT_NUMBER_CONSTRAINT):
+  """True if the number of agents having skill level GE level
+        is greather than or equal to num_agent
+  """
+  skill_level = getattr(subject,skill.__name__.lower() + '_level')
+  return sum(skill_level >= level) / num_agent
+
+@define_predicate
+def CountEvent(gs: GameState,
+               subject: Group               = constraint.TEAM_GROUPS,
+               event: str                   = constraint.EVENTCODE_CONSTRAINT,
+               N: int                       = constraint.ScalarConstraint()):
+  """True if the number of events occured in subject corresponding
+      to event >= N
+  """
+  return len(getattr(subject.event, event)) / N
+
+@define_predicate
+def ScoreHit(gs: GameState,
+             subject: Group                 = constraint.TEAM_GROUPS,
+             combat_style: type[Skill]      = constraint.COMBAT_SKILL_CONSTRAINT,
+             N: int                         = constraint.ScalarConstraint()):
+  """True if the number of hits scored in style
+  combat_style >= count
+  """
+  hits = subject.event.SCORE_HIT.combat_style == combat_style.SKILL_ID
+  return count(hits) / N
+
+@define_predicate
+def HoardGold(gs: GameState,
+              subject: Group                = constraint.TEAM_GROUPS,
+              amount: int                   = constraint.ScalarConstraint()):
+  """True iff the summed gold of all teammate is greater than or equal to amount.
+  """
+  return subject.gold.sum() / amount
+
+@define_predicate
+def EarnGold(gs: GameState,
+             subject: Group                 = constraint.TEAM_GROUPS,
+             amount: int                    = constraint.ScalarConstraint()):
+  """ True if the total amount of gold earned is greater than or equal to amount.
+  """
+  return subject.event.EARN_GOLD.gold.sum() / amount
+
+@define_predicate
+def SpendGold(gs: GameState,
+              subject: Group                = constraint.TEAM_GROUPS,
+              amount: int                   = constraint.ScalarConstraint()):
+  """ True if the total amount of gold spent is greater than or equal to amount.
+  """
+  return subject.event.BUY_ITEM.gold.sum() / amount
+
+@define_predicate
+def MakeProfit(gs: GameState,
+              subject: Group                = constraint.TEAM_GROUPS,
+              amount: int                   = constraint.ScalarConstraint()):
+  """ True if the total amount of gold earned-spent is greater than or equal to amount.
+  """
+  profits = subject.event.EARN_GOLD.gold.sum()
+  costs = subject.event.BUY_ITEM.gold.sum()
+  return  (profits-costs) / amount
+
+@define_predicate
+def InventorySpaceGE(gs: GameState,
+                     subject: Group         = constraint.TEAM_GROUPS,
+                     space: int             = constraint.ScalarConstraint()):
+  """True if the inventory space of every subjects is greater than or equal to
+       the space. Otherwise false.
+  """
+  max_space = gs.config.ITEM_INVENTORY_CAPACITY
+  return all(max_space - inv.len >= space for inv in subject.obs.inventory)
+
+@define_predicate
+def OwnItem(gs: GameState,
+            subject: Group                  = constraint.TEAM_GROUPS,
+            item: type[Item]                = constraint.ITEM_CONSTRAINT,
+            level: int                      = constraint.PROGRESSION_CONSTRAINT,
+            quantity: int                   = constraint.INVENTORY_CONSTRAINT):
+  """True if the number of items owned (_item_type, >= level)
+     is greater than or equal to quantity.
+  """
+  owned = (subject.item.type_id == item.ITEM_TYPE_ID) & \
+          (subject.item.level >= level)
+  return sum(subject.item.quantity[owned]) / quantity
+
+@define_predicate
+def EquipItem(gs: GameState,
+              subject: Group                = constraint.TEAM_GROUPS,
+              item: type[Item]              = constraint.ITEM_CONSTRAINT,
+              level: int                    = constraint.PROGRESSION_CONSTRAINT,
+              num_agent: int                = constraint.AGENT_NUMBER_CONSTRAINT):
+  """True if the number of agents that equip the item (_item_type, >=_level)
+     is greater than or equal to _num_agent.
+  """
+  equipped = (subject.item.type_id == item.ITEM_TYPE_ID) & \
+             (subject.item.level >= level) & \
+             (subject.item.equipped > 0)
+  if num_agent > 0:
+    return count(equipped) / num_agent
+  return 1.0
+
+@define_predicate
+def FullyArmed(gs: GameState,
+               subject: Group               = constraint.TEAM_GROUPS,
+               combat_style: type[Skill]    = constraint.COMBAT_SKILL_CONSTRAINT,
+               level: int                   = constraint.PROGRESSION_CONSTRAINT,
+               num_agent: int               = constraint.AGENT_NUMBER_CONSTRAINT):
+  """True if the number of fully equipped agents is greater than or equal to _num_agent
+       Otherwise false.
+       To determine fully equipped, we look at hat, top, bottom, weapon, ammo, respectively,
+       and see whether these are equipped and has level greater than or equal to _level.
+  """
+  WEAPON_IDS = {
+    nmmo_skill.Melee: {'weapon':5, 'ammo':13}, # Sword, Scrap
+    nmmo_skill.Range: {'weapon':6, 'ammo':14}, # Bow, Shaving
+    nmmo_skill.Mage: {'weapon':7, 'ammo':15} # Wand, Shard
+  }
+  item_ids = { 'hat':2, 'top':3, 'bottom':4 }
+  item_ids.update(WEAPON_IDS[combat_style])
+
+  lvl_flt = (subject.item.level >= level) & \
+            (subject.item.equipped > 0)
+  type_flt = np.isin(subject.item.type_id,list(item_ids.values()))
+  _, equipment_numbers = np.unique(subject.item.owner_id[lvl_flt & type_flt],
+                                   return_counts=True)
+  if num_agent > 0:
+    return (equipment_numbers >= len(item_ids.items())).sum() / num_agent
+  return 1.0
+
+@define_predicate
+def ConsumeItem(gs: GameState,
+                subject: Group              = constraint.TEAM_GROUPS,
+                item: type[Item]            = constraint.CONSUMABLE_CONSTRAINT,
+                level: int                  = constraint.PROGRESSION_CONSTRAINT,
+                quantity: int               = constraint.ScalarConstraint()):
+  """True if total quantity consumed of item type above level is >= quantity
+  """
+  type_flt = subject.event.CONSUME_ITEM.type == item.ITEM_TYPE_ID
+  lvl_flt = subject.event.CONSUME_ITEM.level >= level
+  return subject.event.CONSUME_ITEM.number[type_flt & lvl_flt].sum() / quantity
+
+@define_predicate
+def HarvestItem(gs: GameState,
+                subject: Group              = constraint.TEAM_GROUPS,
+                item: type[Item]            = constraint.ITEM_CONSTRAINT,
+                level: int                  = constraint.PROGRESSION_CONSTRAINT,
+                quantity: int               = constraint.ScalarConstraint()):
+  """True if total quantity harvested of item type above level is >= quantity
+  """
+  type_flt = subject.event.HARVEST_ITEM.type == item.ITEM_TYPE_ID
+  lvl_flt = subject.event.HARVEST_ITEM.level >= level
+  return subject.event.HARVEST_ITEM.number[type_flt & lvl_flt].sum() / quantity
+
+@define_predicate
+def ListItem(gs: GameState,
+             subject: Group                 = constraint.TEAM_GROUPS,
+             item: type[Item]               = constraint.ITEM_CONSTRAINT,
+             level: int                     = constraint.PROGRESSION_CONSTRAINT,
+             quantity: int                  = constraint.ScalarConstraint()):
+  """True if total quantity listed of item type above level is >= quantity
+  """
+  type_flt = subject.event.LIST_ITEM.type == item.ITEM_TYPE_ID
+  lvl_flt = subject.event.LIST_ITEM.level >= level
+  return subject.event.LIST_ITEM.number[type_flt & lvl_flt].sum() / quantity
+
+@define_predicate
+def BuyItem(gs: GameState,
+            subject: Group                  = constraint.TEAM_GROUPS,
+            item: type[Item]                = constraint.ITEM_CONSTRAINT,
+            level: int                      = constraint.PROGRESSION_CONSTRAINT,
+            quantity: int                   = constraint.ScalarConstraint()):
+  """True if total quantity purchased of item type above level is >= quantity
+  """
+  type_flt = subject.event.BUY_ITEM.type == item.ITEM_TYPE_ID
+  lvl_flt = subject.event.BUY_ITEM.level >= level
+  return subject.event.BUY_ITEM.number[type_flt & lvl_flt].sum() / quantity
diff --git a/nmmo/task/constraint.py b/nmmo/task/constraint.py
new file mode 100644
index 000000000..630944444
--- /dev/null
+++ b/nmmo/task/constraint.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+import random
+from numbers import Number
+from typing import Union, Callable
+from abc import ABC, abstractmethod
+
+from nmmo.systems import skill, item
+from nmmo.lib import material
+from nmmo.core.config import Config
+from nmmo.task.team_helper import TeamHelper
+
+class InvalidConstraint(Exception):
+  pass
+
+class Constraint(ABC):
+  """ To check the validity of predicates
+  and assist generating new predicates. Similar to gym spaces.
+  """
+  def __init__(self, systems=None):
+    if systems is None:
+      systems = []
+    self._systems = systems
+
+  # pylint: disable=unused-argument
+  def check(self, config: Config, value):
+    """ Checks value is in bounds given config
+    """
+    for system in self._systems:
+      try:
+        if not getattr(config,system):
+          return False
+      except AttributeError:
+        return False
+    return True
+
+  @abstractmethod
+  def sample(self, config: Config):
+    """ Generator to sample valid values given config
+    """
+    raise NotImplementedError
+
+  def __str__(self):
+    return self.__class__.__name__
+
+class GroupConstraint(Constraint):
+  """ Ensures that all agents of a group exist in a config
+  """
+  def __init__(self,
+               sample_fn = lambda c: TeamHelper.generate_from_config(c).all_teams,
+               systems = None):
+    """
+    Params
+      sample_fn: given a Config, return groups to select from
+      systems: systems required to operate
+    """
+    super().__init__(systems)
+    self._sample_fn = sample_fn
+
+  def check(self, config, value):
+    if not super().check(config,value):
+      return False
+    for agent in value.agents:
+      if agent > config.PLAYER_N:
+        return False
+    return True
+
+  def sample(self, config):
+    return random.choice(self._sample_fn(config))
+
+class ScalarConstraint(Constraint):
+  def __init__(self,
+               low:  Union[Callable, Number] = 0,
+               high: Union[Callable, Number] = 1024,
+               dtype = int,
+               systems = None):
+    super().__init__(systems)
+    self._low = low
+    self._high = high
+    if isinstance(low, Number):
+      self._low  = lambda _ : low
+    if isinstance(high, Number):
+      self._high = lambda _ : high
+    self._dtype = dtype
+
+  def check(self, config, value):
+    if not super().check(config,value):
+      return False
+    if self._low(config) <= value < self._high(config):
+      return True
+    return False
+
+  def sample(self, config):
+    l, h = self._low(config), self._high(config)
+    return self._dtype(random.random()*(h-l)+l)
+
+class DiscreteConstraint(Constraint):
+  def __init__(self, space, systems=None):
+    super().__init__(systems)
+    self._space = space
+
+  def check(self, config: Config, value):
+    if not super().check(config,value):
+      return False
+    return value in self._space
+
+  def sample(self, config: Config):
+    return random.choice(self._space)
+
+# Group Constraints
+TEAM_GROUPS = GroupConstraint()
+INDIVIDUAL_GROUPS=GroupConstraint(sample_fn=lambda c:TeamHelper.generate_from_config(c).all_agents)
+
+# System Constraints
+MATERIAL_CONSTRAINT = DiscreteConstraint(space=list(material.All.materials),
+                                         systems=['TERRAIN_SYSTEM_ENABLED',
+                                                  'RESOURCE_SYSTEM_ENABLED'])
+HABITABLE_CONSTRAINT = DiscreteConstraint(space=list(material.Habitable.materials),
+                                         systems=['TERRAIN_SYSTEM_ENABLED'])
+combat_skills = [skill.Melee, skill.Mage, skill.Range]
+basic_skills = [skill.Water, skill.Food]
+harvest_skills = [skill.Fishing, skill.Herbalism, skill.Prospecting, skill.Alchemy, skill.Carving]
+SKILL_CONSTRAINT = DiscreteConstraint(space=combat_skills+basic_skills+harvest_skills,
+                                      systems=['PROFESSION_SYSTEM_ENABLED'])
+COMBAT_SKILL_CONSTRAINT = DiscreteConstraint(space=combat_skills,
+                                      systems=['PROFESSION_SYSTEM_ENABLED'])
+EVENTCODE_CONSTRAINT = DiscreteConstraint(space=['EAT_FOOD',
+                                                 'DRINK_WATER',
+                                                 'SCORE_HIT',
+                                                 'PLAYER_KILL',
+                                                 'CONSUME_ITEM',
+                                                 'GIVE_ITEM',
+                                                 'DESTROY_ITEM',
+                                                 'HARVEST_ITEM',
+                                                 'GIVE_GOLD',
+                                                 'LIST_ITEM',
+                                                 'EARN_GOLD',
+                                                 'BUY_ITEM'])
+armour = [item.Hat, item.Top, item.Bottom]
+weapons = [item.Sword, item.Bow, item.Wand]
+tools = [item.Chisel, item.Gloves, item.Rod, item.Pickaxe, item.Arcane]
+ammunition = [item.Shard, item.Shaving, item.Scrap]
+consumables = [item.Poultice, item.Ration]
+ITEM_CONSTRAINT = DiscreteConstraint(space=armour+weapons+tools+ammunition+consumables,
+                                     systems=['ITEM_SYSTEM_ENABLED'])
+CONSUMABLE_CONSTRAINT = DiscreteConstraint(space=consumables,
+                                           systems=['ITEM_SYSTEM_ENABLED'])
+# Config Constraints
+COORDINATE_CONSTRAINT = ScalarConstraint(high = lambda c: c.MAP_CENTER)
+PROGRESSION_CONSTRAINT = ScalarConstraint(high = lambda c: c.PROGRESSION_LEVEL_MAX+1)
+INVENTORY_CONSTRAINT = ScalarConstraint(high=lambda c: c.ITEM_INVENTORY_CAPACITY+1)
+AGENT_NUMBER_CONSTRAINT = ScalarConstraint(low = 1, high = lambda c: c.PLAYER_N+1)
diff --git a/nmmo/task/game_state.py b/nmmo/task/game_state.py
new file mode 100644
index 000000000..583d0797f
--- /dev/null
+++ b/nmmo/task/game_state.py
@@ -0,0 +1,206 @@
+from __future__ import annotations
+from typing import Dict, List, Tuple, MutableMapping
+from dataclasses import dataclass
+from copy import deepcopy
+from abc import ABC, abstractmethod
+
+import numpy as np
+
+from nmmo.core.config import Config
+from nmmo.core.realm import Realm
+from nmmo.core.observation import Observation
+from nmmo.task.group import Group
+
+from nmmo.entity.entity import EntityState
+from nmmo.lib.event_log import EventState, ATTACK_COL_MAP, ITEM_COL_MAP, LEVEL_COL_MAP
+from nmmo.lib.log import EventCode
+from nmmo.systems.item import ItemState
+from nmmo.core.tile import TileState
+
+EntityAttr = EntityState.State.attr_name_to_col
+EventAttr = EventState.State.attr_name_to_col
+ItemAttr = ItemState.State.attr_name_to_col
+TileAttr = TileState.State.attr_name_to_col
+EventAttr.update(ITEM_COL_MAP)
+EventAttr.update(ATTACK_COL_MAP)
+EventAttr.update(LEVEL_COL_MAP)
+
+@dataclass(frozen=True) # make gs read-only, except cache_result
+class GameState:
+  current_tick: int
+  config: Config
+  spawn_pos: Dict[int, Tuple[int, int]] # ent_id: (row, col) of all spawned agents
+
+  alive_agents: List[int] # of alive agents' ent_id (for convenience)
+  env_obs: Dict[int, Observation] # env passes the obs of only alive agents
+
+  entity_data: np.ndarray # a copied, whole Entity ds table
+  item_data: np.ndarray # a copied, whole Item ds table
+  event_data: np.ndarray # a copied, whole Event log table
+
+  cache_result: MutableMapping # cache for general memoization
+  # add helper functions below
+  def entity_or_none(self, ent_id):
+    flt_ent = self.entity_data[:, EntityAttr['id']] == ent_id
+    if np.any(flt_ent):
+      return EntityAttr.parse_array(self.entity_data[flt_ent][0])
+
+    return None
+
+  def where_in_id(self, data_type, subject: List[int]):
+    if data_type == 'entity':
+      flt_idx = np.in1d(self.entity_data[:, EntityAttr['id']], subject)
+      return self.entity_data[flt_idx]
+    if data_type == 'item':
+      flt_idx = np.in1d(self.item_data[:, ItemAttr['owner_id']], subject)
+      return self.item_data[flt_idx]
+    if data_type == 'event':
+      flt_idx = np.in1d(self.event_data[:, EventAttr['ent_id']], subject)
+      return self.event_data[flt_idx]
+    raise ValueError("data_type must be in entity, item, event")
+
+  def get_subject_view(self, subject: Group):
+    return GroupView(self, subject)
+
+# Wrapper around an iterable datastore
+class ArrayView(ABC):
+  def __init__(self,
+               mapping,
+               name: str,
+               gs: GameState,
+               subject: Group,
+               arr: np.ndarray):
+    self._mapping = mapping
+    self._name = name
+    self._gs = gs
+    self._subject = subject
+    self._arr = arr
+
+  def __len__(self):
+    return len(self._arr)
+
+  @abstractmethod
+  def get_attribute(self, attr) -> np.ndarray:
+    raise NotImplementedError
+
+  def __getattr__(self, attr) -> np.ndarray:
+    k = (self._subject, self._name+'_'+attr)
+    if k in self._gs.cache_result:
+      return self._gs.cache_result[k]
+    v = object.__getattribute__(self, 'get_attribute')(attr)
+    self._gs.cache_result[k] = v
+    return v
+
+class ItemView(ArrayView):
+  def __init__(self, gs: GameState, subject: Group, arr: np.ndarray):
+    super().__init__(ItemAttr, 'item', gs, subject, arr)
+    self._mapping = ItemAttr
+
+  def get_attribute(self, attr) -> np.ndarray:
+    return self._arr[:, self._mapping[attr]]
+
+class EntityView(ArrayView):
+  def __init__(self, gs: GameState, subject: Group, arr: np.ndarray):
+    super().__init__(EntityAttr, 'entity', gs, subject, arr)
+
+  def get_attribute(self, attr) -> np.ndarray:
+    return self._arr[:, self._mapping[attr]]
+
+class EventView(ArrayView):
+  def __init__(self, gs: GameState, subject: Group, arr: np.ndarray):
+    super().__init__(EventAttr, 'event', gs, subject, arr)
+
+  def get_attribute(self, attr) -> np.ndarray:
+    assert hasattr(EventCode, attr), 'Invalid event code'
+    arr = self._arr[np.in1d(self._arr[:, EventAttr['event']],
+                                          getattr(EventCode, attr))]
+    return EventCodeView(attr, self._gs, self._subject, arr)
+
+class TileView(ArrayView):
+  def __init__(self, gs: GameState, subject: Group, arr: np.ndarray):
+    super().__init__(TileAttr, 'tile', gs, subject, arr)
+
+  def get_attribute(self, attr) -> np.ndarray:
+    return [o[:, self._mapping[attr]]for o in self._arr]
+
+class EventCodeView(ArrayView):
+  def __init__(self,
+               name: str,
+               gs: GameState,
+               subject: Group,
+               arr: np.ndarray):
+    super().__init__(EventAttr, name, gs, subject, arr)
+
+  def get_attribute(self, attr) -> np.ndarray:
+    return self._arr[:, self._mapping[attr]]
+
+# Group
+class GroupObsView:
+  def __init__(self, gs: GameState, subject: Group):
+    self._gs = gs
+
+    valid_agents = filter(lambda eid: eid in gs.env_obs,subject.agents)
+    self._obs = [gs.env_obs[ent_id] for ent_id in valid_agents]
+    self._subject = subject
+    self.tile = TileView(gs, subject, [o.tiles for o in self._obs])
+
+  def __getattr__(self, attr):
+    return [getattr(o, attr) for o in self._obs]
+
+class GroupView:
+  def __init__(self, gs: GameState, subject: Group):
+    self._gs = gs
+    self._subject = subject
+    self._sbj_ent = gs.where_in_id('entity', subject.agents)
+    self._sbj_item = gs.where_in_id('item', subject.agents)
+    self._sbj_event = gs.where_in_id('event', subject.agents)
+
+    self.entity = EntityView(gs, subject, self._sbj_ent)
+    self.item = ItemView(gs, subject, self._sbj_item)
+    self.event = EventView(gs, subject, self._sbj_event)
+    self.obs = GroupObsView(gs, subject)
+
+  def __getattribute__(self, attr):
+    if attr in ['_gs','_subject','_sbj_ent','_sbj_item','entity','item','event','obs']:
+      return object.__getattribute__(self,attr)
+
+    # Cached optimization
+    k = (self._subject, attr)
+    if k in self._gs.cache_result:
+      return self._gs.cache_result[k]
+
+    try:
+      # Get property
+      if attr in EntityAttr.keys():
+        v = getattr(self.entity, attr)
+      else:
+        v = object.__getattribute__(self, attr)
+      self._gs.cache_result[k] = v
+      return v
+    except AttributeError:
+      # View behavior
+      return object.__getattribute__(self._gs,attr)
+
+class GameStateGenerator:
+  def __init__(self, realm: Realm, config: Config):
+    self.config = deepcopy(config)
+    self.spawn_pos: Dict[int, Tuple[int, int]] = {}
+
+    for ent_id, ent in realm.players.items():
+      self.spawn_pos.update( {ent_id: ent.pos} )
+
+  def generate(self, realm: Realm, env_obs: Dict[int, Observation]) -> GameState:
+    # copy the datastore, by running astype
+    entity_all = EntityState.Query.table(realm.datastore).astype(np.int16)
+
+    return GameState(
+      current_tick = realm.tick,
+      config = self.config,
+      spawn_pos = self.spawn_pos,
+      alive_agents = list(entity_all[:, EntityAttr["id"]]),
+      env_obs = env_obs,
+      entity_data = entity_all,
+      item_data = ItemState.Query.table(realm.datastore).astype(np.int16),
+      event_data = EventState.Query.table(realm.datastore).astype(np.int16),
+      cache_result = {}
+    )
diff --git a/nmmo/task/group.py b/nmmo/task/group.py
new file mode 100644
index 000000000..29aa1b0b2
--- /dev/null
+++ b/nmmo/task/group.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+from typing import Dict,  Iterable, TYPE_CHECKING
+from collections import OrderedDict
+from collections.abc import Set, Sequence
+
+if TYPE_CHECKING:
+  from nmmo.task.game_state import GameState, GroupView
+
+class Group(Sequence, Set):
+  ''' An immutable, ordered, unique group of agents involved in a task
+  '''
+  def __init__(self,
+               agents: Iterable[int],
+               name: str=None):
+
+    assert len(agents) > 0, "Team must have at least one agent"
+    self.name = name if name else f"Agent({','.join([str(e) for e in agents])})"
+    # Remove duplicates
+    self._agents = tuple(OrderedDict.fromkeys(sorted(agents)).keys())
+    if not isinstance(self._agents,tuple):
+      self._agents = (self._agents,)
+
+    self._sd: GroupView = None
+    self._gs: GameState = None
+
+  @property
+  def agents(self):
+    return self._agents
+
+  def union(self, o: Group):
+    return Group(self._agents + o.agents)
+
+  def intersection(self, o: Group):
+    return Group(set(self._agents).intersection(set(o.agents)))
+
+  def __eq__(self, o):
+    return self._agents == o
+
+  def __len__(self):
+    return len(self._agents)
+
+  def __hash__(self):
+    return hash(self._agents)
+
+  def __getitem__(self, key):
+    if len(self) == 1 and key == 0:
+      return self
+    return Group((self._agents[key],), f"{self.name}.{key}")
+
+  def __contains__(self, key):
+    if isinstance(key, int):
+      return key in self.agents
+    return Sequence.__contains__(self, key)
+
+  def __str__(self) -> str:
+    return str(self._agents)
+
+  def __int__(self) -> int:
+    assert len(self._agents) == 1, "Group is not a singleton"
+    return int(self._agents[0])
+
+  def __copy__(self):
+    return self
+  def __deepcopy__(self, memo):
+    return Group(self.agents, self.name)
+
+  def description(self) -> Dict:
+    return {
+      "type": "Group",
+      "name": self.name,
+      "agents": self._agents
+    }
+
+  def update(self, gs: GameState) -> None:
+    self._gs = gs
+    self._sd = gs.get_subject_view(self)
+
+  def __getattr__(self, attr):
+    return self._sd.__getattribute__(attr)
+
+def union(*groups: Group) -> Group:
+  """ Performs a big union over groups
+  """
+  agents = []
+  for group in groups:
+    for agent in group.agents:
+      agents.append(agent)
+  return Group(agents)
+
+def complement(group: Group, universe: Group) -> Group:
+  """ Returns the complement of group in universe
+  """
+  agents = []
+  for agent in universe.agents:
+    if not agent in group:
+      agents.append(agent)
+  return Group(agents)
diff --git a/nmmo/task/scenario.py b/nmmo/task/scenario.py
new file mode 100644
index 000000000..7efeb2106
--- /dev/null
+++ b/nmmo/task/scenario.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+import copy
+from typing import Callable, Union, Iterable, \
+  Optional, List, Tuple
+from nmmo.core.config import Config
+from nmmo.task.group import Group
+from nmmo.task.team_helper import TeamHelper
+from nmmo.task.task_api import Task, Repeat
+from nmmo.task.base_predicates import StayAlive
+
+class Scenario:
+  ''' Utility class to aid in defining common tasks
+  '''
+  def __init__(self, config: Config):
+    config = copy.deepcopy(config)
+    self.team_helper = TeamHelper.generate_from_config(config)
+    self.config = config
+    self._tasks: List[Task] = []
+
+  def add_task(self, task: Task):
+    self._tasks.append(task)
+
+  def add_tasks(self,
+                tasks: Union[Task,
+                             Iterable[Task],
+                             Callable[[Group], Task]],
+                groups: Optional[Union[str,Iterable[Group]]] = 'teams') -> None:
+    # pylint: disable=unnecessary-lambda-assignment
+    """ Utility function to define symmetric tasks
+
+    Params:
+
+      tasks:
+        Iterable[Task]:
+          For each Task in the iterable, add to scenario.
+        Callable[[Group], Task]:
+          A function taking in a group and return a task.
+          The result from applying this function to "groups" is added to 
+          the scenario.
+        Task: 
+          Mapped to Callable by overriding subject
+
+      groups: 
+        Foreach group in groups, add a task.
+    """
+    # Tasks
+    if isinstance(tasks, Iterable):
+      for task in tasks:
+        self.add_task(task)
+      return
+
+    # Functional Syntax
+      # Tasks
+    if isinstance(tasks, Task):
+      task_generator = lambda group: tasks.sample(config=self.config, subject=group)
+    else:
+      task_generator = tasks
+      # Groups
+    if isinstance(groups, str):
+      assert(groups in ['agents','teams'])
+    if groups == 'agents':
+      groups = self.team_helper.all_agents
+    elif groups == 'teams':
+      groups = self.team_helper.all_teams
+      # Create
+    self.add_tasks([task_generator(group) for group in groups])
+
+  @property
+  def tasks(self) -> List[Task]:
+    return self._tasks
+
+def default_task(agents) -> List[Tuple[Task, float]]:
+  '''Generates the default reward on env.init
+  '''
+  return [Repeat(StayAlive(Group([agent]))) for agent in agents]
diff --git a/nmmo/task/task_api.py b/nmmo/task/task_api.py
new file mode 100644
index 000000000..df18db83b
--- /dev/null
+++ b/nmmo/task/task_api.py
@@ -0,0 +1,375 @@
+from __future__ import annotations
+from typing import Callable, Dict, List, Optional, Tuple, Union
+from abc import ABC, abstractmethod
+import inspect
+from numbers import Real
+import math
+
+from nmmo.core.config import Config
+from nmmo.task.group import Group, union
+from nmmo.task.game_state import GameState
+from nmmo.task.constraint import Constraint, InvalidConstraint, GroupConstraint
+
+class InvalidTaskDefinition(Exception):
+  pass
+
+class Task(ABC):
+  """ A task is used to calculate rewards for agents in "assignee"
+  """
+  def __init__(self,
+               subject: Group,
+               *args,
+               constraints: Optional[List[Tuple[str,Optional[Constraint]]]] = None,
+               **kwargs):
+    self.name = self._make_name(self.__class__.__name__, args, kwargs)
+
+    def is_group(x):
+      return isinstance(x, Group)
+    self._groups: List[Group] = list(filter(is_group, args))
+    self._groups = self._groups + list(filter(is_group, kwargs.values()))
+    self._groups.append(subject)
+
+    self._args = args
+    self._kwargs = kwargs
+    self._constraints = constraints
+    self._config = None
+    self._score = 0.0
+    self._subject = subject
+
+  def compute_rewards(self, gs) -> Tuple[Dict[int, float], Dict[int, Dict]]:
+    """ Environment facing API
+
+    Returns rewards and infos for all agents in subject
+    """
+    reward = self(gs) - self._score
+    self._score += reward
+    rewards = {int(ent_id): reward for ent_id in self._subject}
+    infos = {int(ent_id): {self.name: self._score}
+             for ent_id in self._subject}
+    return rewards, infos
+
+  def __call__(self, gs: GameState) -> float:
+    """ Calculates score
+
+    Params:
+      gs: GameState
+
+    Returns:
+      score
+    """
+    if not self._config == gs.config:
+      # TODO(mark) should we make this explicitly called by environment
+      self._reset(gs.config)
+    # Update views
+    for group in self._groups:
+      group.update(gs)
+    # Calculate score
+    cache = gs.cache_result
+    if self.name in cache:
+      score = cache[self.name]
+    else:
+      score = self._evaluate(gs)
+      cache[self.name] = score
+    # Calculate score
+    return score
+
+  def _reset(self, config: Config):
+    self._score = 0.0
+    self._config = config
+    if not self.check(self._config):
+      raise InvalidConstraint()
+
+  def check(self, config: Config):
+    """ Checks whether the task is valid
+
+    A satisfiable task "makes sense" given a config
+    ie. Not trying to reach target off the map
+    """
+    if not GroupConstraint().check(config, self._subject):
+      return False
+    for i, (name, constraint) in enumerate(self._constraints):
+      if constraint is None:
+        continue
+      if i < len(self._args):
+        if not constraint.check(config, self._args[i]):
+          return False
+      elif not constraint.check(config, self._kwargs[name]):
+        return False
+    return True
+
+  def sample(self, config: Config, **overload):
+    """ Samples a concrete instance of a given task.
+    
+    Allows overloading of previous parameters.
+    """
+    # Sample Constraint
+    nargs = [arg.sample(config) if isinstance(arg, Constraint) else arg
+              for arg in self._args]
+    nkwargs = {k : v.sample(config) if isinstance(v, Constraint) else v
+                for k,v in self._kwargs.items()}
+    for i, (name, _) in enumerate(self._constraints):
+      if i < len(nargs):
+        if name in nkwargs:
+          raise InvalidTaskDefinition("Constraints should match arguments.")
+        nkwargs[name] = nargs[i]
+      else:
+        break
+
+    for k, v in overload.items():
+      nkwargs[k] = v
+     # Result
+    return self.__class__(**nkwargs)
+
+  @abstractmethod
+  def _evaluate(self, gs: GameState) -> float:
+    """ A mapping from a game state to the desirability of that state.
+    """
+    raise NotImplementedError
+
+  def _make_name(self, class_name, args, kwargs) -> str:
+    def arg_to_string(arg):
+      if isinstance(arg, type): # class
+        return arg.__name__
+      if arg is None:
+        return 'Any'
+      return str(arg)
+
+    name = [class_name] + \
+      list(map(arg_to_string, args)) + \
+      [f"{arg_to_string(key)}:{arg_to_string(arg)}" for key, arg in kwargs.items()]
+    name = "("+'_'.join(name).replace(' ', '')+")"
+    return name
+
+  def __str__(self):
+    return self.name
+
+  @property
+  def subject(self):
+    return self._subject
+
+  def __add__(self, other):
+    return ADD(self, other)
+  def __radd__(self, other):
+    return ADD(self, other)
+  def __mul__(self, other):
+    return MUL(self, other)
+  def __rmul__(self, other):
+    return MUL(self, other)
+  def __and__(self, other):
+    return AND(self, other)
+  def __or__(self, other):
+    return OR(self, other)
+  def __invert__(self):
+    return NOT(self)
+
+class Predicate(Task):
+  """ A task with evaluate restricted to boolean values.
+
+  True = 1.0
+  False = 0.0
+  """
+  def __call__(self, gs: GameState) -> float:
+    if not self._config == gs.config:
+      self._reset(gs.config)
+    # Update views
+    for group in self._groups:
+      group.update(gs)
+    # Calculate score
+    cache = gs.cache_result
+    if self.name in cache:
+      score = cache[self.name]
+    else:
+      score = max(min(self._evaluate(gs)*1,1.0),0.0)
+      cache[self.name] = score
+    # Calculate score
+    return score
+
+  def __and__(self, other):
+    return PAND(self, other)
+  def __or__(self, other):
+    return POR(self, other)
+  def __invert__(self):
+    return PNOT(self)
+  def __rshift__(self, other):
+    return IMPLY(self, other)
+
+################################################
+
+def define_task(fn: Callable) -> type[Task]:
+  """ Syntactic sugar API for defining tasks
+
+  See examples at base_predicates.py
+  """
+  signature = inspect.signature(fn)
+  for i, param in enumerate(signature.parameters.values()):
+    if i == 0 and param.name != 'gs':
+      raise InvalidTaskDefinition('First parameter must be gs: GameState')
+    if i == 1 and (param.name != 'subject'):
+      raise InvalidTaskDefinition("Second parameter must be subject: Group")
+
+  class FunctionTask(Task):
+    def __init__(self, *args, **kwargs) -> None:
+      constraints = []
+      self._signature = signature
+      args = list(args)
+      for i, param in enumerate(self._signature.parameters.values()):
+        if i == 0:
+          continue
+        # Calculate list of constraints
+        if isinstance(param.default, Constraint):
+          constraints.append((param.name,param.default))
+        else:
+          constraints.append((param.name,None))
+        # Insert default values from function definition
+        if not param.name in kwargs and i-1 >= len(args):
+          if param.default == inspect.Parameter.empty:
+            args.append(param.default)
+          else:
+            kwargs[param.name] = param.default
+      super().__init__(*args, **kwargs, constraints=constraints)
+      self._args = args
+      self._kwargs = kwargs
+      self.name = self._make_name(fn.__name__, args, kwargs)
+    def _evaluate(self, gs: GameState) -> float:
+      # pylint: disable=redefined-builtin, unused-variable
+      __doc = fn.__doc__
+      result = fn(gs, *self._args, **self._kwargs)
+      if isinstance(result, Task):
+        return result(gs)
+      return result
+
+  return FunctionTask
+
+def define_predicate(fn: Callable) -> type[Predicate]:
+  T = define_task(fn)
+  class FunctionPredicate(Predicate, T):
+    # pylint: disable=super-init-not-called
+    def __init__(self, *args, **kwargs) -> None:
+      T.__init__(self, *args, **kwargs)
+  return FunctionPredicate
+
+################################################
+class TaskOperator(Task):
+  def __init__(self, n, *tasks: Union[Task, Real] ,subject: Group=None):
+    if not n(len(tasks)):
+      raise InvalidTaskDefinition(f"Need {n} arguments")
+    tasks = list(tasks)
+    self._subject_argument = subject
+    if subject is None:
+      try:
+        subject = union(*[t.subject for t in filter(lambda t: isinstance(t, Task), tasks)])
+      except AttributeError:
+        subject = GroupConstraint()
+    super().__init__(subject, *tasks)
+
+    for i, t in enumerate(tasks):
+      if isinstance(t, Real):
+        tasks[i] = lambda _,v=tasks[i] : v
+    self._tasks = tasks
+
+  def check(self, config: Config) -> bool:
+    return all((t.check(config) if isinstance(t, Task) else True for t in self._tasks))
+
+  def sample(self, config: Config, cls: type[TaskOperator], **kwargs):
+    subject = self._subject_argument if 'subject' not in kwargs else kwargs['subject']
+    tasks = [t.sample(config, **kwargs) if isinstance(t, Task) else t(None) for t in self._tasks]
+    return cls(*tasks, subject=subject)
+class OR(TaskOperator):
+  def __init__(self, *tasks: Union[Task, Real], subject: Group=None):
+    super().__init__(lambda n: n>0, *tasks, subject=subject)
+  def _evaluate(self, gs: GameState) -> float:
+    return max(t(gs) for t in self._tasks)
+  def sample(self, config: Config, **kwargs):
+    return super().sample(config, OR, **kwargs)
+
+class AND(TaskOperator):
+  def __init__(self, *tasks: Union[Task, Real], subject: Group=None):
+    super().__init__(lambda n: n>0, *tasks, subject=subject)
+  def _evaluate(self, gs: GameState) -> float:
+    return min(t(gs) for t in self._tasks)
+  def sample(self, config: Config, **kwargs):
+    return super().sample(config, AND, **kwargs)
+
+class NOT(TaskOperator):
+  def __init__(self, *tasks: Union[Task, Real], subject: Group=None):
+    super().__init__(lambda n: n>0, *tasks, subject=subject)
+  def _evaluate(self, gs: GameState) -> float:
+    return -sum(t(gs) for t in self._tasks)
+  def sample(self, config: Config, **kwargs):
+    return super().sample(config, NOT, **kwargs)
+
+class ADD(TaskOperator):
+  def __init__(self, *tasks: Union[Task, Real], subject: Group=None):
+    super().__init__(lambda n: n>0, *tasks, subject=subject)
+  def _evaluate(self, gs: GameState) -> float:
+    return sum(t(gs) for t in self._tasks)
+  def sample(self, config: Config, **kwargs):
+    return super().sample(config, ADD, **kwargs)
+
+class MUL(TaskOperator):
+  def __init__(self, *tasks: Union[Task, Real], subject: Group=None):
+    super().__init__(lambda n: n>0, *tasks, subject=subject)
+  def _evaluate(self, gs: GameState) -> float:
+    result = 1.0
+    for t in self._tasks:
+      result = result * t(gs)
+    return result
+  def sample(self, config: Config, **kwargs):
+    return super().sample(config, MUL, **kwargs)
+
+class POR(TaskOperator, Predicate):
+  def __init__(self, *tasks: Predicate, subject: Group=None):
+    super().__init__(lambda n: n>0, *tasks, subject=subject)
+  def _evaluate(self, gs: GameState) -> float:
+    return any(t(gs) for t in self._tasks)
+  def sample(self, config: Config, **kwargs):
+    return super().sample(config, POR, **kwargs)
+
+class PAND(TaskOperator, Predicate):
+  def __init__(self, *tasks: Predicate, subject: Group=None):
+    super().__init__(lambda n: n>0, *tasks, subject=subject)
+  def _evaluate(self, gs: GameState) -> float:
+    return all(t(gs) for t in self._tasks)
+  def sample(self, config: Config, **kwargs):
+    return super().sample(config, PAND, **kwargs)
+
+class PNOT(TaskOperator, Predicate):
+  def __init__(self, task: Predicate, subject: Group=None):
+    super().__init__(lambda n: n==1, task, subject=subject)
+  def _evaluate(self, gs: GameState) -> float:
+    return not self._tasks[0](gs)
+  def sample(self, config: Config, **kwargs):
+    return super().sample(config, PNOT, **kwargs)
+
+class IMPLY(TaskOperator, Predicate):
+  def __init__(self, p: Predicate, q: Predicate, subject: Group=None):
+    super().__init__(lambda n: n==2, p,q, subject=subject)
+  def _evaluate(self, gs: GameState) -> float:
+    if self._tasks[0](gs):
+      return self._tasks[1](gs)
+    return True
+  def sample(self, config: Config, **kwargs):
+    return super().sample(config, IMPLY, **kwargs)
+
+class Once(TaskOperator):
+  def __init__(self, task: Task, subject: Group=None):
+    super().__init__(lambda n: n==1, task, subject=subject)
+    self._maximum_score = -math.inf
+  def _evaluate(self, gs: GameState) -> float:
+    self._maximum_score = max(self._maximum_score, self._tasks[0](gs))
+    return self._maximum_score
+  def sample(self, config: Config, **kwargs):
+    return super().sample(config, Once, **kwargs)
+
+class Repeat(TaskOperator):
+  def __init__(self, task: Task, subject: Group=None):
+    super().__init__(lambda n: n==1, task, subject=subject)
+    self._current_score = 0
+  def _evaluate(self, gs: GameState) -> float:
+    self._current_score += self._tasks[0](gs)
+    return self._current_score
+  def sample(self, config: Config, **kwargs):
+    return super().sample(config, Repeat, **kwargs)
+
+# TODO(mark) should we define the remaining available operators
+# such as multiply, modulo...
diff --git a/nmmo/task/team_helper.py b/nmmo/task/team_helper.py
new file mode 100644
index 000000000..a8b0aa67b
--- /dev/null
+++ b/nmmo/task/team_helper.py
@@ -0,0 +1,57 @@
+from typing import List
+from nmmo.task.group import Group
+
+class TeamHelper:
+  ''' Provides a mapping from ent_id to group as equivalent to the grouping
+  expected by the policy
+  '''
+
+  def __init__(self, agents: List[int], num_teams: int):
+    assert len(agents) % num_teams == 0
+    self.team_size = len(agents) // num_teams
+    self._team_to_ent, self._ent_to_team = self._map_ent_team(agents, num_teams)
+
+  def _map_ent_team(self, agents, num_teams):
+    _team_to_ent = {}
+    _ent_to_team = {}
+    for ent_id in agents:
+      # to assigne agent 1 to team 0, and so forth
+      pop_id = (ent_id - 1) % num_teams
+      _ent_to_team[ent_id] = pop_id
+      if pop_id in _team_to_ent:
+        _team_to_ent[pop_id].append(ent_id)
+      else:
+        _team_to_ent[pop_id] = [ent_id]
+
+    return _team_to_ent, _ent_to_team
+
+  def team(self, pop_id: int) -> Group:
+    assert pop_id in self._team_to_ent, "Wrong pop_id"
+    return Group(self._team_to_ent[pop_id], f"Team.{pop_id}")
+
+  def own_team(self, ent_id: int) -> Group:
+    assert ent_id in self._ent_to_team, "Wrong ent_id"
+    pop_id = self._ent_to_team[ent_id]
+    return Group(self._team_to_ent[pop_id], f"Team.{pop_id}")
+
+  def left_team(self, ent_id: int) -> Group:
+    assert ent_id in self._ent_to_team, "Wrong ent_id"
+    pop_id = (self._ent_to_team[ent_id] - 1) % len(self._team_to_ent)
+    return Group(self._team_to_ent[pop_id], f"Team.{pop_id}")
+
+  def right_team(self, ent_id: int) -> Group:
+    assert ent_id in self._ent_to_team, "Wrong ent_id"
+    pop_id = (self._ent_to_team[ent_id] + 1) % len(self._team_to_ent)
+    return Group(self._team_to_ent[pop_id], f"Team.{pop_id}")
+
+  @property
+  def all_agents(self) -> Group:
+    return Group(list(self._ent_to_team.keys()), "All")
+
+  @property
+  def all_teams(self) -> List[Group]:
+    return list((Group(v,str(k)) for k,v in self._team_to_ent.items()))
+
+  @staticmethod
+  def generate_from_config(config):
+    return TeamHelper(list(range(1, config.PLAYER_N+1)), len(config.PLAYERS))
diff --git a/offline_dataset.py b/offline_dataset.py
deleted file mode 100644
index 38a7a6fb1..000000000
--- a/offline_dataset.py
+++ /dev/null
@@ -1,152 +0,0 @@
-# pylint: disable=all
-
-import numpy as np
-import h5py
-
-import nmmo
-
-
-class OfflineDataset:
-    def __init__(self, name):
-        self.name = name
-
-    def load(self):
-        f = h5py.File(f'{self.name}.hdf5', 'r')
-        self.obs = f.get('obs')
-        self.atn = f.get('atn')
-        self.rewards = f.get('rewards')
-        self.dones = f.get('dones')
-        return self
-
-    def create(self, obs_dim, atn_dim, horizon, num_episodes, num_players):
-        f = h5py.File(f'{self.name}.hdf5', 'w')
-        self.obs = f.create_dataset('obs', (horizon, num_episodes, num_players, obs_dim), dtype='f')
-        self.atn = f.create_dataset('atn', (horizon, num_episodes, num_players, atn_dim), dtype='i')
-        self.rewards = f.create_dataset('rewards', (horizon, num_episodes, num_players), dtype='f')
-        self.dones = f.create_dataset('dones', (horizon, num_episodes, num_players), dtype=np.dtype('bool'))
-        return self
-
-    def write(self, t, episode, obs=None, atn=None, rewards=None, dones=None):
-        '''Write a single timestep of a single episode
-
-        obs, rewards, dones are as returned from the env
-        atn is the action dict submitted to the env
-        t, episode are indices '''
-        if obs is not None:
-            self.obs[t, episode] = np.stack(list(obs.values()))
-        if atn is not None:
-            self.atn[t, episode] = np.stack(list(atn.values()))
-        if rewards is not None:
-            self.rewards[t, episode] = np.stack(list(rewards.values()))
-        if dones is not None:
-            self.dones[t, episode] = np.stack(list(dones.values()))
-
-    def write_vectorized(self, t, episode, obs=None, atn=None, rewards=None, dones=None):
-        if obs is not None:
-            self.obs[t, episode] = obs
-        if atn is not None:
-            self.atn[t, episode] = atn
-        if rewards is not None:
-            self.rewards[t, episode] = rewards
-        if dones is not None:
-            self.dones[t, episode] = dones
-
-EPISODES = 5
-HORIZON = 16
-#EPISODES = 1000
-#HORIZON = 1023
-
-actions = np.load('actions.npy', allow_pickle=True)
-
-config = nmmo.config.Default()
-env = nmmo.integrations.CleanRLEnv(config, seed=42)
-
-print('Creating h5 dataset')
-dataset = OfflineDataset('nmmo0').create(
-    obs_dim=env.observation_space(0).shape[0],
-    atn_dim = env.action_space(0).shape[0],
-    horizon=HORIZON,
-    num_episodes=EPISODES,
-    num_players=config.PLAYER_N
-)
-
-print('Collecting Actions')
-for episode in range(EPISODES):
-    print(f'Reset {episode}')
-    obs = env.reset()
-    for t in range(HORIZON):
-        # Compute actions from network
-        #atn = {i+1: [0, 0, 0, 0, 0, 0, 0, 0] for i in range(config.PLAYER_N)}
-        dataset.write(t, episode, atn=actions[t])
-
-        # Be sure to .copy() the atn dict -- nmmo modifies it in place
-        obs, rewards, dones, infos = env.step({})
-
-print('Generating Dataset')
-for episode in range(EPISODES):
-    print(f'Reset {episode}')
-    obs = env.reset()
-    for t in range(HORIZON):
-        # Retrieve action from dataset
-        atn = {i+1: e for i, e in enumerate(dataset.atn[t, episode])}
-
-        # Be sure to .copy() the atn dict -- nmmo modifies it in place
-        nxt_obs, rewards, dones, infos = env.step({})
-
-        dataset.write(t, episode, obs=obs, rewards=rewards, dones=dones)
-
-        # Currently discards the last obs -- not sure if this is needed
-        obs = nxt_obs
-
-env = nmmo.integrations.CleanRLEnv(config, seed=42)
-
-print('Creating h5 dataset')
-dataset = OfflineDataset('nmmo1').create(
-    obs_dim=env.observation_space(0).shape[0],
-    atn_dim = env.action_space(0).shape[0],
-    horizon=HORIZON,
-    num_episodes=EPISODES,
-    num_players=config.PLAYER_N
-)
-
-print('Collecting Actions')
-for episode in range(EPISODES):
-    print(f'Reset {episode}')
-    obs = env.reset()
-    for t in range(HORIZON):
-        # Compute actions from network
-        atn = {i+1: [0, 0, 0, 0, 0, 0, 0, 0] for i in range(config.PLAYER_N)}
-        dataset.write(t, episode, atn=actions[t])
-
-        # Be sure to .copy() the atn dict -- nmmo modifies it in place
-        obs, rewards, dones, infos = env.step({})
-
-print('Generating Dataset')
-for episode in range(EPISODES):
-    print(f'Reset {episode}')
-    obs = env.reset()
-    for t in range(HORIZON):
-        # Retrieve action from dataset
-        atn = {i+1: e for i, e in enumerate(dataset.atn[t, episode])}
-
-        # Be sure to .copy() the atn dict -- nmmo modifies it in place
-        nxt_obs, rewards, dones, infos = env.step({})
-
-        dataset.write(t, episode, obs=obs, rewards=rewards, dones=dones)
-
-        # Currently discards the last obs -- not sure if this is needed
-        obs = nxt_obs
-
-
-print('Loading h5 dataset')
-dataset = OfflineDataset('nmmo0').load()
-print(dataset.obs)
-print(dataset.atn)
-print(dataset.rewards)
-print(dataset.dones)
-
-dataset1 = OfflineDataset('nmmo1').load()
-print(np.sum(dataset.obs[:] != dataset1.obs[:]))
-print(np.sum(dataset.atn[:] != dataset1.atn[:]))
-print(np.sum(dataset.rewards[:] != dataset1.rewards[:]))
-print(np.sum(dataset.dones[:] != dataset1.dones[:]))
diff --git a/save_atns.py b/save_atns.py
deleted file mode 100644
index fd0f69ddc..000000000
--- a/save_atns.py
+++ /dev/null
@@ -1,8 +0,0 @@
-HORIZON = 16
-import nmmo
-import numpy as np
-
-config = nmmo.config.Default()
-env = nmmo.integrations.CleanRLEnv(config, seed=42)
-actions =  [{e: env.action_space(1).sample() for e in range(1, config.PLAYER_N+1)} for _ in range(HORIZON)] 
-np.save('actions.npy', actions)
diff --git a/scripts/requirements.txt b/scripts/requirements.txt
deleted file mode 100644
index 2e6d30742..000000000
--- a/scripts/requirements.txt
+++ /dev/null
@@ -1,17 +0,0 @@
-autobahn==19.3.3
-bokeh==2.2.3
-fire==0.4.0
-gym==0.17.2
-imageio==2.8.0
-lovely-tensors==0.1.8
-lovely-numpy==0.1.8
-matplotlib==3.1.3
-numpy==1.21.1
-pettingzoo==1.13.1
-sphinx-rtd-theme==0.5.1
-sphinxcontrib-youtube==1.0.1
-tqdm==4.61.1
-trueskill==0.4.5
-Twisted==19.2.0
-vec-noise==1.1.4
-wandb==0.10.32
\ No newline at end of file
diff --git a/scripts/rllib_requirements.txt b/scripts/rllib_requirements.txt
deleted file mode 100644
index 9cf7d6f38..000000000
--- a/scripts/rllib_requirements.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-ray==1.5.2
-cloudpickle==1.2.2
-setproctitle==1.1.10
-redis==3.5.3
-dm-tree==0.1.5
-tensorflow==2.4.1
-aioredis==1.3.1
diff --git a/scripts/setup.sh b/scripts/setup.sh
deleted file mode 100644
index 8d11de080..000000000
--- a/scripts/setup.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-read -p "DEPENDENCIES: this script requires Anaconda Python 3.8, gcc, zlib1g-dev, make, cmake, and build-essential. Installation will fail otherwise. Proceed [y/n]?" -n 1 -r
-echo
-if [[ ! $REPLY =~ ^[Yy]$ ]]
-then
-   [[ "$0" = "$BASH_SOURCE" ]] && exit 1 || return 1
-fi
-
-#Install python packages
-echo "Installing conda pip packages"
-pip install -r scripts/requirements.txt
-
-if [[ $1 == "--CORE_ONLY" ]]; then 
-   echo "You have chosen not to install RLlib and associated dependencies for learned models"
-elif [[ $1 == "" ]]; then
-   echo "Installing additional RLlib dependencies..."
-   pip install -r scripts/rllib_requirements.txt
-   pip install ray[rllib]
-   echo "Installing cuda torch"
-   conda install pytorch torchvision torchaudio cudatoolkit=11.0 -c pytorch
-else
-   echo "Specify either --CORE_ONLY or no argument"
-   exit 1
-fi
-
-echo "Done. Errors? Check that dependencies have been met"
diff --git a/setup.py b/setup.py
index 9c571226b..ac649cd8a 100644
--- a/setup.py
+++ b/setup.py
@@ -17,50 +17,52 @@
 }
 
 extra['all'] = list(set(chain.from_iterable(extra.values())))
- 
+
+with open('nmmo/version.py', encoding="utf-8") as vf:
+  ver = vf.read().split()[-1].strip("'")
+
 setup(
-    name="nmmo",
-    description="Neural MMO is a platform for multiagent intelligence research inspired by "
-    "Massively Multiplayer Online (MMO) role-playing games. Documentation hosted at neuralmmo.github.io.",
-    long_description_content_type="text/markdown",
-    version=open('nmmo/version.py').read().split()[-1].strip("'"),
-    packages=find_packages(),
-    include_package_data=True,
-    install_requires=[
-        'numpy==1.23.3',
-        'scipy==1.10.0',
-        'pytest==7.3.0',
-        'pytest-benchmark==3.4.1',
-        'fire==0.4.0',
-        'autobahn==19.3.3',
-        'Twisted==19.2.0',
-        'vec-noise==1.1.4',
-        'imageio==2.23.0',
-        'tqdm==4.61.1',
-        'h5py==3.7.0',
-        'pettingzoo==1.19.0',
-        'gym==0.23.0',
-        'pylint==2.16.0',
-        'py==1.11.0'
-    ],
-    extras_require=extra,
-    python_requires=">=3.7",
-    license="MIT",
-    author="Joseph Suarez",
-    author_email="jsuarez@mit.edu",
-    url=REPO_URL,
-    keywords=["Neural MMO", "MMO"],
-    classifiers=[
-        "Development Status :: 5 - Production/Stable",
-        "Intended Audience :: Science/Research",
-        "Intended Audience :: Developers",
-        "Environment :: Console",
-        "License :: OSI Approved :: MIT License",
-        "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
-        "Programming Language :: Python :: 3.9",
-        "Programming Language :: Python :: 3.10",
-        "Programming Language :: Python :: 3.11",
-    ],
+  name="nmmo",
+  description="Neural MMO is a platform for multiagent intelligence research " + \
+              "inspired by Massively Multiplayer Online (MMO) role-playing games. " + \
+              "Documentation hosted at neuralmmo.github.io.",
+  long_description_content_type="text/markdown",
+  version=ver,
+  packages=find_packages(),
+  include_package_data=True,
+  install_requires=[
+    'numpy==1.23.3',
+    'scipy==1.10.0',
+    'pytest==7.3.0',
+    'pytest-benchmark==3.4.1',
+    'fire==0.4.0',
+    'autobahn==19.3.3',
+    'Twisted==19.2.0',
+    'vec-noise==1.1.4',
+    'imageio==2.23.0',
+    'ordered-set==4.1.0',
+    'pettingzoo==1.19.0',
+    'gym==0.23.0',
+    'pylint==2.16.0',
+    'py==1.11.0',
+    'tqdm<5',
+  ],
+  extras_require=extra,
+  python_requires=">=3.7",
+  license="MIT",
+  author="Joseph Suarez",
+  author_email="jsuarez@mit.edu",
+  url=REPO_URL,
+  keywords=["Neural MMO", "MMO"],
+  classifiers=[
+    "Development Status :: 5 - Production/Stable",
+    "Intended Audience :: Science/Research",
+    "Intended Audience :: Developers",
+    "Environment :: Console",
+    "License :: OSI Approved :: MIT License",
+    "Programming Language :: Python :: 3.7",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+  ],
 )
-
diff --git a/tools/task_generator.py b/tools/task_generator.py
deleted file mode 100644
index 505b9c4e2..000000000
--- a/tools/task_generator.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import argparse
-import nmmo.lib.task as task
-
-if __name__ == "__main__":
-    parser = argparse.ArgumentParser()
-    parser.add_argument("--tasks", type=int, default=10)
-    parser.add_argument("--num_teams", type=int, default=10)
-    parser.add_argument("--team_size", type=int, default=1)
-    parser.add_argument("--min_clauses", type=int, default=1)
-    parser.add_argument("--max_clauses", type=int, default=1)
-    parser.add_argument("--min_clause_size", type=int, default=1)
-    parser.add_argument("--max_clause_size", type=int, default=1)
-    parser.add_argument("--not_p", type=float, default=0.5)
-    
-    flags = parser.parse_args()
-
-    team_helper = task.TeamHelper(range(flags.team_size * flags.num_teams), flags.num_teams)
-    sampler = task.TaskSampler.create_default_task_sampler(team_helper, 0)
-    for i in range(flags.tasks):
-      task = sampler.sample(
-        flags.min_clauses, flags.max_clauses, 
-        flags.min_clause_size, flags.max_clause_size, flags.not_p)
-      print(task.to_string())
-
-
-


Test patch:
diff --git a/quicktest.py b/quicktest.py
deleted file mode 100644
index 6b8e507f1..000000000
--- a/quicktest.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import nmmo
-from nmmo.core.config import Medium
-
-
-def create_config(base, *systems):
-    systems   = (base, *systems)
-    name      = '_'.join(cls.__name__ for cls in systems)
-    conf      = type(name, systems, {})()
-
-    conf.TERRAIN_TRAIN_MAPS = 1
-    conf.TERRAIN_EVAL_MAPS  = 1
-    conf.IMMORTAL = True
-    conf.RENDER   = True
-
-    return conf
-
-def benchmark_config(base, nent, *systems):
-    conf = create_config(base, *systems)
-    conf.PLAYER_N = nent
-    env = nmmo.Env(conf)
-    env.reset()
-
-    env.render()
-    while True:
-        env.step(actions={})
-        env.render()
-
-benchmark_config(Medium, 100)
-
diff --git a/run_tests.sh b/run_tests.sh
deleted file mode 100755
index 27305a3e6..000000000
--- a/run_tests.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-pytest -rP --benchmark-columns=ops,mean,stddev,min,max,iterations,rounds --benchmark-max-time=5 --benchmark-min-rounds=1 --benchmark-sort=name tests/test_performance.py
-#pytest --benchmark-columns=ops,mean,stddev,min,max,iterations,rounds --benchmark-max-time=5 --benchmark-min-rounds=1 --benchmark-sort=name
diff --git a/tests/task/.gitignore b/tests/task/.gitignore
new file mode 100644
index 000000000..a0f7381ba
--- /dev/null
+++ b/tests/task/.gitignore
@@ -0,0 +1,1 @@
+test_held_out_tasks.py
\ No newline at end of file
diff --git a/tests/task/test_demo_task_creation.py b/tests/task/test_demo_task_creation.py
new file mode 100644
index 000000000..4aaa1894e
--- /dev/null
+++ b/tests/task/test_demo_task_creation.py
@@ -0,0 +1,186 @@
+import unittest
+from tests.testhelpers import ScriptedAgentTestConfig
+
+from nmmo.core.env import Env
+from nmmo.lib.log import EventCode
+from nmmo.systems import skill
+from nmmo.task import base_predicates as p
+from nmmo.task import task_api as t
+from nmmo.task.game_state import GameState
+from nmmo.task.group import Group
+from nmmo.task.scenario import Scenario
+
+class TestDemoTask(unittest.TestCase):
+
+  def test_baseline_tasks(self):
+    # Tasks from
+    # https://github.com/NeuralMMO/baselines/
+    # blob/4c1088d2bbe0f74a08dcf7d71b714cd30772557f/tasks.py
+    class Tier:
+      REWARD_SCALE = 15
+      EASY         = 4 / REWARD_SCALE
+      NORMAL       = 6 / REWARD_SCALE
+      HARD         = 11 / REWARD_SCALE
+
+    # Usage of inbuilt predicate
+    def player_kills(scenario: Scenario):
+      scenario.add_tasks(p.CountEvent(event='PLAYER_KILL',N=1)*Tier.EASY)
+      scenario.add_tasks(p.CountEvent(event='PLAYER_KILL',N=2)*Tier.NORMAL)
+      scenario.add_tasks(p.CountEvent(event='PLAYER_KILL',N=3)*Tier.HARD)
+      return scenario.tasks
+
+    def exploration(scenario: Scenario):
+      scenario.add_tasks(p.DistanceTraveled(dist=16)*Tier.EASY)
+      scenario.add_tasks(p.DistanceTraveled(dist=32)*Tier.NORMAL)
+      scenario.add_tasks(p.DistanceTraveled(dist=64)*Tier.HARD)
+      return scenario.tasks
+
+    # Demonstrates custom predicate - return float/boolean
+    @t.define_predicate
+    def EquipmentLevel(gs: GameState,
+                       subject: Group,
+                       number: int):
+      equipped = (subject.item.equipped>0)
+      levels = subject.item.level[equipped]
+      return levels.sum() >= number
+
+    def equipment(scenario: Scenario):
+      scenario.add_tasks(EquipmentLevel(number=1 )*Tier.EASY, groups='agents')
+      scenario.add_tasks(EquipmentLevel(number=5 )*Tier.NORMAL, groups='agents')
+      scenario.add_tasks(EquipmentLevel(number=10)*Tier.HARD, groups='agents')
+      return scenario.tasks
+
+    @t.define_predicate
+    def CombatSkill(gs, subject, lvl):
+        return t.OR(p.AttainSkill(subject, skill.Melee, lvl, 1),
+                  p.AttainSkill(subject, skill.Range, lvl, 1),
+                  p.AttainSkill(subject, skill.Mage, lvl, 1))
+
+    def combat(scenario: Scenario):
+      scenario.add_tasks(CombatSkill(lvl=2)*Tier.EASY, groups='agents')
+      scenario.add_tasks(CombatSkill(lvl=3)*Tier.NORMAL, groups='agents')
+      scenario.add_tasks(CombatSkill(lvl=4)*Tier.HARD, groups='agents')
+      return scenario.tasks
+
+    @t.define_predicate
+    def ForageSkill(gs, subject, lvl):
+        return t.OR(p.AttainSkill(subject, skill.Fishing, lvl, 1),
+                  p.AttainSkill(subject, skill.Herbalism, lvl, 1),
+                  p.AttainSkill(subject, skill.Prospecting, lvl, 1),
+                  p.AttainSkill(subject, skill.Carving, lvl, 1),
+                  p.AttainSkill(subject, skill.Alchemy, lvl, 1))
+
+    def foraging(scenario: Scenario):
+      scenario.add_tasks(ForageSkill(lvl=2)*Tier.EASY)
+      scenario.add_tasks(ForageSkill(lvl=3)*Tier.NORMAL)
+      scenario.add_tasks(ForageSkill(lvl=4)*Tier.HARD)
+      return scenario.tasks
+
+    # Demonstrate task scenario definition API
+    def all_tasks(scenario: Scenario):
+      player_kills(scenario)
+      exploration(scenario)
+      equipment(scenario)
+      combat(scenario)
+      foraging(scenario)
+      return scenario.tasks
+
+    # Test rollout
+    task_generators = [player_kills, exploration, equipment, combat, foraging, all_tasks]
+    for tg in task_generators:
+      config = ScriptedAgentTestConfig()
+      env = Env(config)
+      scenario = Scenario(config)
+      tasks = tg(scenario)
+      env.change_task(tasks)
+      for _ in range(10):
+        env.step({})
+
+    # DONE
+
+  def test_player_kill_reward(self):
+    """ Reward 0.1 per player defeated, 1 for first and 3rd kills
+    """
+    config = ScriptedAgentTestConfig()
+    env = Env(config)
+    scenario = Scenario(config)
+
+    # PARTICIPANT WRITES
+    # ====================================
+    @t.define_task
+    def KillTask(gs: GameState,
+                 subject: Group):
+      """ Reward 0.1 per player defeated, with a bonus for the 1st and 3rd kills.
+      """
+      num_kills = len(subject.event.PLAYER_KILL)
+      score = num_kills * 0.1
+      if num_kills >= 1:
+        score += 1
+      if num_kills >= 3:
+        score += 1
+      return score
+
+    scenario.add_tasks(lambda agent: KillTask(agent), groups='agents')
+    # ====================================
+
+    # Test Reward
+    env.change_task(scenario.tasks)
+    players = env.realm.players
+    code = EventCode.PLAYER_KILL
+    env.realm.event_log.record(code, players[1], target=players[3])
+    env.realm.event_log.record(code, players[2], target=players[4])
+    env.realm.event_log.record(code, players[2], target=players[5])
+    env.realm.event_log.record(EventCode.EAT_FOOD, players[2])
+      # Award given as designed
+      # Agent 1 kills 1 - reward 1 + 0.1
+      # Agent 2 kills 2 - reward 1 + 0.2
+      # Agent 3 kills 0 - reward 0
+    _, rewards, _, _ = env.step({})
+    self.assertEqual(rewards[1],1.1)
+    self.assertEqual(rewards[2],1.2)
+    self.assertEqual(rewards[3],0)
+      # No reward when no changes
+    _, rewards, _, _ = env.step({})
+    self.assertEqual(rewards[1],0)
+    self.assertEqual(rewards[2],0)
+    self.assertEqual(rewards[3],0)
+      # Test task reset on env reset
+    env.reset() 
+    _, rewards, _, _ = env.step({})
+    self.assertEqual(env.tasks[0][0]._score,0)
+
+    # Test Rollout
+    env.change_task(scenario.tasks)
+    for _ in range(10):
+       env.step({})
+
+    # DONE
+
+  def test_combination_task_reward(self):
+    config = ScriptedAgentTestConfig()
+    env = Env(config)
+    scenario = Scenario(config)
+
+    task = t.OR(p.CountEvent(event='PLAYER_KILL',N=5),p.TickGE(num_tick=5))
+    task = task * 5
+    scenario.add_tasks(task)
+
+    # Test Reward
+    env.change_task(scenario.tasks)
+    code = EventCode.PLAYER_KILL
+    players = env.realm.players
+    env.realm.event_log.record(code, players[1], target=players[2])
+    env.realm.event_log.record(code, players[1], target=players[3])
+
+    _, rewards, _, _ = env.step({})
+    self.assertEqual(rewards[1],2)
+
+    for _ in range(4):
+      _, _, _, infos = env.step({})
+    
+    self.assertEqual(list(infos[1]['task'].values())[0],5.0)
+
+    # DONE
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/tests/task/test_predicates.py b/tests/task/test_predicates.py
new file mode 100644
index 000000000..531d11477
--- /dev/null
+++ b/tests/task/test_predicates.py
@@ -0,0 +1,676 @@
+import unittest
+from typing import List, Tuple
+import random
+
+from tests.testhelpers import ScriptedAgentTestConfig, provide_item
+from tests.testhelpers import change_spawn_pos as change_agent_pos
+
+from scripted.baselines import Sleeper
+
+from nmmo.entity.entity import EntityState
+from nmmo.systems import item as Item
+from nmmo.systems import skill as Skill
+from nmmo.lib import material as Material
+from nmmo.lib.log import EventCode
+
+# pylint: disable=import-error
+from nmmo.core.env import Env
+from nmmo.task.task_api import Task, Repeat, TaskOperator
+from nmmo.task.group import Group
+import nmmo.task.base_predicates as bp
+
+# use the constant reward of 1 for testing predicates
+NUM_AGENT = 6
+ALL_AGENT = Group(list(range(1, NUM_AGENT+1)), 'All')
+
+class Change(TaskOperator):
+  def __init__(self, task: Task, subject: Group=None):
+    super().__init__(lambda n: n==1, task, subject=subject)
+  def _evaluate(self, gs) -> float:
+    return self._tasks[0](gs)
+  def sample(self, config, **kwargs):
+    return super().sample(config, Change, **kwargs)
+
+class TestBasePredicate(unittest.TestCase):
+  # pylint: disable=protected-access,invalid-name
+
+  def _get_taskenv(self,
+                   test_tasks: List[Tuple[Task, Group]],
+                   grass_map=False):
+
+    config = ScriptedAgentTestConfig()
+    config.PLAYERS = [Sleeper]
+    config.PLAYER_N = NUM_AGENT
+    config.IMMORTAL = True
+
+    tasks = [Change(tsk, subject=team) for tsk, team in test_tasks]
+
+    env = Env(config)
+    env.change_task(tasks)
+
+    if grass_map:
+      MS = env.config.MAP_SIZE
+      # Change entire map to grass to become habitable
+      for i in range(MS):
+        for j in range(MS):
+          tile = env.realm.map.tiles[i,j]
+          tile.material = Material.Grass
+          tile.material_id.update(Material.Grass.index)
+          tile.state = Material.Grass(env.config)
+
+    return env
+
+  def _check_result(self, env, test_tasks, infos, true_task):
+    for tid, (task, assignee) in enumerate(test_tasks):
+      # result is cached when at least one assignee is alive so that the task is evaled
+      if set(assignee).intersection(infos):
+        self.assertEqual(int(env.game_state.cache_result[task.name]), tid in true_task)
+      for ent_id in infos:
+        if ent_id in assignee:
+          # the agents that are assigned the task get evaluated for reward
+          self.assertEqual(int(infos[ent_id]['task'][Change(task,assignee).name]), int(tid in true_task))
+        else:
+          # the agents that are not assigned the task are not evaluated
+          self.assertTrue(task.name not in infos[ent_id]['task'])
+
+  def _check_progress(self, task, infos, value):
+    """ Some predicates return a float in the range 0-1 indicating completion progress.
+    """
+    predicate, assignee = task[0], task[1]
+    for ent_id in infos:
+      if ent_id in assignee:
+        self.assertAlmostEqual(infos[ent_id]['task'][Change(predicate,assignee).name],value)
+
+  def test_tickge_stay_alive_rip(self):
+    tick_true = 5
+    death_note = [1, 2, 3]
+    test_tasks = [ # (Predicate, Team)
+      (bp.TickGE(Group([1]), tick_true), ALL_AGENT),
+      (bp.StayAlive(Group([1, 3])), ALL_AGENT),
+      (bp.StayAlive(Group([3, 4])), Group([1, 2])),
+      (bp.StayAlive(Group([4])), Group([5, 6])),
+      (bp.AllDead(Group([1, 3])), ALL_AGENT),
+      (bp.AllDead(Group([3, 4])), Group([1, 2])),
+      (bp.AllDead(Group([4])), Group([5, 6]))]
+
+    env = self._get_taskenv(test_tasks)
+
+    for _ in range(tick_true-1):
+      _, _, _, infos = env.step({})
+
+    # TickGE_5 is false. All agents are alive,
+    # so all StayAlive (ti in [1,2,3]) tasks are true
+    # and all AllDead tasks (ti in [4, 5, 6]) are false
+
+    true_task = [1, 2, 3]
+    self._check_result(env, test_tasks, infos, true_task)
+    self._check_progress(test_tasks[0], infos, (tick_true-1) / tick_true)
+
+    # kill agents 1-3
+    for ent_id in death_note:
+      env.realm.players[ent_id].resources.health.update(0)
+    env.obs = env._compute_observations()
+
+    # 6th tick
+    _, _, _, infos = env.step({})
+
+    # those who have survived
+    entities = EntityState.Query.table(env.realm.datastore)
+    entities = list(entities[:, EntityState.State.attr_name_to_col['id']]) # ent_ids
+
+    # make sure the dead agents are not in the realm & datastore
+    for ent_id in env.realm.players.spawned:
+      if ent_id in death_note:
+        # make sure that dead players not in the realm nor the datastore
+        self.assertTrue(ent_id not in env.realm.players)
+        self.assertTrue(ent_id not in entities)
+        # CHECK ME: dead agents are also not in infos
+        self.assertTrue(ent_id not in infos)
+
+    # TickGE_5 is true. Agents 1-3 are dead, so
+    # StayAlive(1,3) and StayAlive(3,4) are false, StayAlive(4) is true
+    # AllDead(1,3) is true, AllDead(3,4) and AllDead(4) are false
+    true_task = [0, 3, 4]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # 3 is dead but 4 is alive. Half of agents killed, 50% completion.
+    self._check_progress(test_tasks[5], infos, 0.5)
+
+    # DONE
+
+  def test_can_see_tile(self):
+    a1_target = Material.Forest
+    a2_target = Material.Water
+    test_tasks = [ # (Predicate, Team), the reward is 1 by default
+      (bp.CanSeeTile(Group([1]), a1_target), ALL_AGENT), # True
+      (bp.CanSeeTile(Group([1,3,5]), a2_target), ALL_AGENT), # False
+      (bp.CanSeeTile(Group([2]), a2_target), Group([1,2,3])), # True
+      (bp.CanSeeTile(Group([2,5,6]), a1_target), ALL_AGENT), # False
+      (bp.CanSeeTile(ALL_AGENT, a2_target), Group([2,3,4]))] # True
+
+    # setup env with all grass map
+    env = self._get_taskenv(test_tasks, grass_map=True)
+
+    # Two corners to the target materials
+    MS = env.config.MAP_SIZE
+    tile = env.realm.map.tiles[0,MS-2]
+    tile.material = Material.Forest
+    tile.material_id.update(Material.Forest.index)
+
+    tile = env.realm.map.tiles[MS-1,0]
+    tile.material = Material.Water
+    tile.material_id.update(Material.Water.index)
+
+    # All agents to one corner
+    for ent_id in env.realm.players:
+      change_agent_pos(env.realm,ent_id,(0,0))
+
+    env.obs = env._compute_observations()
+    _, _, _, infos = env.step({})
+
+    # no target tiles are found, so all are false
+    true_task = []
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # Team one to forest, team two to water
+    change_agent_pos(env.realm,1,(0,MS-2)) # agent 1, team 0, forest
+    change_agent_pos(env.realm,2,(MS-2,0)) # agent 2, team 1, water
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+
+    # t0, t2, t4 are true
+    true_task = [0, 2, 4]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+
+  def test_can_see_agent(self):
+    search_target = 1
+    test_tasks = [ # (Predicate, Team), the reward is 1 by default
+      (bp.CanSeeAgent(Group([1]), search_target), ALL_AGENT), # Always True
+      (bp.CanSeeAgent(Group([2]), search_target), Group([2,3,4])), # False -> True -> True
+      (bp.CanSeeAgent(Group([3,4,5]), search_target), Group([1,2,3])), # False -> False -> True
+      (bp.CanSeeGroup(Group([1]), Group([3,4])), ALL_AGENT)] # False -> False -> True
+
+    env = self._get_taskenv(test_tasks, grass_map=True)
+
+    # All agents to one corner
+    for ent_id in env.realm.players:
+      change_agent_pos(env.realm,ent_id,(0,0))
+
+    # Teleport agent 1 to the opposite corner
+    MS = env.config.MAP_SIZE
+    change_agent_pos(env.realm,1,(MS-2,MS-2))
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+
+    # Only CanSeeAgent(Group([1]), search_target) is true, others are false
+    true_task = [0]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # Teleport agent 2 to agent 1's pos
+    change_agent_pos(env.realm,2,(MS-2,MS-2))
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+
+    # SearchAgent(Team([2]), search_target) is also true
+    true_task = [0,1]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # Teleport agent 3 to agent 1s position
+    change_agent_pos(env.realm,3,(MS-2,MS-2))
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+    true_task = [0,1,2,3]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+
+  def test_occupy_tile(self):
+    target_tile = (30, 30)
+    test_tasks = [ # (Predicate, Team), the reward is 1 by default
+      (bp.OccupyTile(Group([1]), *target_tile), ALL_AGENT), # False -> True
+      (bp.OccupyTile(Group([1,2,3]), *target_tile), Group([4,5,6])), # False -> True
+      (bp.OccupyTile(Group([2]), *target_tile), Group([2,3,4])), # False
+      (bp.OccupyTile(Group([3,4,5]), *target_tile), Group([1,2,3]))] # False
+
+    # make all tiles habitable
+    env = self._get_taskenv(test_tasks, grass_map=True)
+
+    # All agents to one corner
+    for ent_id in env.realm.players:
+      change_agent_pos(env.realm,ent_id,(0,0))
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+
+    # all tasks must be false
+    true_task = []
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # teleport agent 1 to the target tile, agent 2 to the adjacent tile
+    change_agent_pos(env.realm,1,target_tile)
+    change_agent_pos(env.realm,2,(target_tile[0],target_tile[1]-1))
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+
+    # tid 0 and 1 should be true: OccupyTile(Group([1]), *target_tile)
+    #  & OccupyTile(Group([1,2,3]), *target_tile)
+    true_task = [0, 1]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+
+  def test_distance_traveled(self):
+    agent_dist = 6
+    team_dist = 10
+    # NOTE: when evaluating predicates, to whom tasks are assigned are irrelevant
+    test_tasks = [ # (Predicate, Team), the reward is 1 by default
+      (bp.DistanceTraveled(Group([1]), agent_dist), ALL_AGENT), # False -> True
+      (bp.DistanceTraveled(Group([2, 5]), agent_dist), ALL_AGENT), # False
+      (bp.DistanceTraveled(Group([3, 4]), agent_dist), ALL_AGENT), # False
+      (bp.DistanceTraveled(Group([1, 2, 3]), team_dist), ALL_AGENT), # False -> True
+      (bp.DistanceTraveled(Group([6]), agent_dist), ALL_AGENT)] # False
+
+    # make all tiles habitable
+    env = self._get_taskenv(test_tasks, grass_map=True)
+
+    _, _, _, infos = env.step({})
+
+    # one cannot accomplish these goals in the first tick, so all false
+    true_task = []
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # all are sleeper, so they all stay in the spawn pos
+    spawn_pos = { ent_id: ent.pos for ent_id, ent in env.realm.players.items() }
+    ent_id = 1 # move 6 tiles, to reach the goal
+    change_agent_pos(env.realm, ent_id, (spawn_pos[ent_id][0]+6, spawn_pos[ent_id][1]))
+    ent_id = 2 # move 2, fail to reach agent_dist, but reach team_dist if add all
+    change_agent_pos(env.realm, ent_id, (spawn_pos[ent_id][0]+2, spawn_pos[ent_id][1]))
+    ent_id = 3 # move 3, fail to reach agent_dist, but reach team_dist if add all
+    change_agent_pos(env.realm, ent_id, (spawn_pos[ent_id][0], spawn_pos[ent_id][1]+3))
+    env.obs = env._compute_observations()
+
+    _,_,_, infos = env.step({})
+
+    true_task = [0, 3]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+
+  def test_all_members_within_range(self):
+    dist_123 = 1
+    dist_135 = 5
+    test_tasks = [ # (Predicate, Team), the reward is 1 by default
+      (bp.AllMembersWithinRange(Group([1]), dist_123), ALL_AGENT), # Always true for group of 1
+      (bp.AllMembersWithinRange(Group([1,2]), dist_123), ALL_AGENT), # True
+      (bp.AllMembersWithinRange(Group([1,3]), dist_123), ALL_AGENT), # True
+      (bp.AllMembersWithinRange(Group([2,3]), dist_123), ALL_AGENT), # False
+      (bp.AllMembersWithinRange(Group([1,3,5]), dist_123), ALL_AGENT), # False
+      (bp.AllMembersWithinRange(Group([1,3,5]), dist_135), ALL_AGENT), # True
+      (bp.AllMembersWithinRange(Group([2,4,6]), dist_135), ALL_AGENT)] # False
+
+    # make all tiles habitable
+    env = self._get_taskenv(test_tasks, grass_map=True)
+
+    MS = env.config.MAP_SIZE
+
+    # team 0: staying within goal_dist
+    change_agent_pos(env.realm, 1, (MS//2, MS//2))
+    change_agent_pos(env.realm, 3, (MS//2-1, MS//2)) # also StayCloseTo a1 = True
+    change_agent_pos(env.realm, 5, (MS//2-5, MS//2))
+
+    # team 1: staying goal_dist+1 apart
+    change_agent_pos(env.realm, 2, (MS//2+1, MS//2)) # also StayCloseTo a1 = True
+    change_agent_pos(env.realm, 4, (MS//2+5, MS//2))
+    change_agent_pos(env.realm, 6, (MS//2+8, MS//2))
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+
+    true_task = [0, 1, 2, 5]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+
+  def test_attain_skill(self):
+    goal_level = 5
+    test_tasks = [ # (Predicate, Team), the reward is 1 by default
+      (bp.AttainSkill(Group([1]), Skill.Melee, goal_level, 1), ALL_AGENT), # False
+      (bp.AttainSkill(Group([2]), Skill.Melee, goal_level, 1), ALL_AGENT), # False
+      (bp.AttainSkill(Group([1]), Skill.Range, goal_level, 1), ALL_AGENT), # True
+      (bp.AttainSkill(Group([1,3]), Skill.Fishing, goal_level, 1), ALL_AGENT), # True
+      (bp.AttainSkill(Group([1,2,3]), Skill.Carving, goal_level, 3), ALL_AGENT), # False
+      (bp.AttainSkill(Group([2,4]), Skill.Carving, goal_level, 2), ALL_AGENT)] # True
+
+    env = self._get_taskenv(test_tasks)
+
+    # AttainSkill(Group([1]), Skill.Melee, goal_level, 1) is false
+    # AttainSkill(Group([2]), Skill.Melee, goal_level, 1) is false
+    env.realm.players[1].skills.melee.level.update(goal_level-1)
+    # AttainSkill(Group([1]), Skill.Range, goal_level, 1) is true
+    env.realm.players[1].skills.range.level.update(goal_level)
+    # AttainSkill(Group([1,3]), Skill.Fishing, goal_level, 1) is true
+    env.realm.players[1].skills.fishing.level.update(goal_level)
+    # AttainSkill(Group([1,2,3]), Skill.Carving, goal_level, 3) is false
+    env.realm.players[1].skills.carving.level.update(goal_level)
+    env.realm.players[2].skills.carving.level.update(goal_level)
+    # AttainSkill(Group([2,4]), Skill.Carving, goal_level, 2) is true
+    env.realm.players[4].skills.carving.level.update(goal_level+2)
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+
+    true_task = [2, 3, 5]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+
+  def test_inventory_space_ge_not(self):
+    # also test NOT InventorySpaceGE
+    target_space = 3
+    test_tasks = [ # (Predicate, Team), the reward is 1 by default
+      (bp.InventorySpaceGE(Group([1]), target_space), ALL_AGENT), # True -> False
+      (bp.InventorySpaceGE(Group([2,3]), target_space), ALL_AGENT), # True
+      (bp.InventorySpaceGE(Group([1,2,3]), target_space), ALL_AGENT), # True -> False
+      (bp.InventorySpaceGE(Group([1,2,3,4]), target_space+1), ALL_AGENT), # False
+      (~bp.InventorySpaceGE(Group([1]), target_space+1), ALL_AGENT), # True
+      (~bp.InventorySpaceGE(Group([1,2,3]), target_space), ALL_AGENT), # False -> True
+      (~bp.InventorySpaceGE(Group([1,2,3,4]), target_space+1), ALL_AGENT)] # True
+
+    env = self._get_taskenv(test_tasks)
+
+    # add one items to agent 1 within the limit
+    capacity = env.realm.players[1].inventory.capacity
+    provide_item(env.realm, 1, Item.Ration, level=1, quantity=capacity-target_space)
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+
+    self.assertTrue(env.realm.players[1].inventory.space >= target_space)
+    true_task = [0, 1, 2, 4, 6]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # add one more item to agent 1
+    provide_item(env.realm, 1, Item.Ration, level=1, quantity=1)
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+
+    self.assertTrue(env.realm.players[1].inventory.space < target_space)
+    true_task = [1, 4, 5, 6]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+
+  def test_own_equip_item(self):
+    # ration, level 2, quantity 3 (non-stackable)
+    # ammo level 2, quantity 3 (stackable, equipable)
+    goal_level = 2
+    goal_quantity = 3
+    test_tasks = [ # (Predicate, Team), the reward is 1 by default
+      (bp.OwnItem(Group([1]), Item.Ration, goal_level, goal_quantity), ALL_AGENT), # False
+      (bp.OwnItem(Group([2]), Item.Ration, goal_level, goal_quantity), ALL_AGENT), # False
+      (bp.OwnItem(Group([1,2]), Item.Ration, goal_level, goal_quantity), ALL_AGENT), # True
+      (bp.OwnItem(Group([3]), Item.Ration, goal_level, goal_quantity), ALL_AGENT), # True
+      (bp.OwnItem(Group([4,5,6]), Item.Ration, goal_level, goal_quantity), ALL_AGENT), # False
+      (bp.EquipItem(Group([4]), Item.Scrap, goal_level, 1), ALL_AGENT), # False
+      (bp.EquipItem(Group([4,5]), Item.Scrap, goal_level, 1), ALL_AGENT), # True
+      (bp.EquipItem(Group([4,5,6]), Item.Scrap, goal_level, 2), ALL_AGENT)] # True
+
+    env = self._get_taskenv(test_tasks)
+
+    # set the level, so that agents 4-6 can equip the scrap
+    equip_scrap = [4, 5, 6]
+    for ent_id in equip_scrap:
+      env.realm.players[ent_id].skills.melee.level.update(6) # melee skill level=6
+
+    # provide items
+    ent_id = 1 # OwnItem(Group([1]), Item.Ration, goal_level, goal_quantity) is false
+    provide_item(env.realm, ent_id, Item.Ration, level=1, quantity=4)
+    provide_item(env.realm, ent_id, Item.Ration, level=2, quantity=2)
+    # OwnItem(Group([2]), Item.Ration, goal_level, goal_quantity) is false
+    ent_id = 2 # OwnItem(Group([1,2]), Item.Ration, goal_level, goal_quantity) is true
+    provide_item(env.realm, ent_id, Item.Ration, level=4, quantity=1)
+    ent_id = 3 # OwnItem(Group([3]), Item.Ration, goal_level, goal_quantity) is true
+    provide_item(env.realm, ent_id, Item.Ration, level=3, quantity=3)
+    # OwnItem(Group([4,5,6]), Item.Ration, goal_level, goal_quantity) is false
+
+    # provide and equip items
+    ent_id = 4 # EquipItem(Group([4]), Item.Scrap, goal_level, 1) is false
+    provide_item(env.realm, ent_id, Item.Scrap, level=1, quantity=4)
+    ent_id = 5 # EquipItem(Group([4,5]), Item.Scrap, goal_level, 1) is true
+    provide_item(env.realm, ent_id, Item.Scrap, level=4, quantity=1)
+    ent_id = 6 # EquipItem(Group([4,5,6]), Item.Scrap, goal_level, 2) is true
+    provide_item(env.realm, ent_id, Item.Scrap, level=2, quantity=4)
+    for ent_id in [4, 5, 6]:
+      scrap = env.realm.players[ent_id].inventory.items[0]
+      scrap.use(env.realm.players[ent_id])
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+
+    true_task = [2, 3, 6, 7]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+
+  def test_fully_armed(self):
+    goal_level = 5
+    test_tasks = [ # (Predicate, Team), the reward is 1 by default
+      (bp.FullyArmed(Group([1,2,3]), Skill.Range, goal_level, 1), ALL_AGENT), # False
+      (bp.FullyArmed(Group([3,4]), Skill.Range, goal_level, 1), ALL_AGENT), # True
+      (bp.FullyArmed(Group([4]), Skill.Melee, goal_level, 1), ALL_AGENT), # False
+      (bp.FullyArmed(Group([4,5,6]), Skill.Range, goal_level, 3), ALL_AGENT), # True
+      (bp.FullyArmed(Group([4,5,6]), Skill.Range, goal_level+3, 1), ALL_AGENT), # False
+      (bp.FullyArmed(Group([4,5,6]), Skill.Range, goal_level, 4), ALL_AGENT)] # False
+
+    env = self._get_taskenv(test_tasks)
+
+    # fully equip agents 4-6
+    fully_equip = [4, 5, 6]
+    for ent_id in fully_equip:
+      env.realm.players[ent_id].skills.range.level.update(goal_level+2)
+      # prepare the items
+      item_list = [ itm(env.realm, goal_level) for itm in [
+        Item.Hat, Item.Top, Item.Bottom, Item.Bow, Item.Shaving]]
+      for itm in item_list:
+        env.realm.players[ent_id].inventory.receive(itm)
+        itm.use(env.realm.players[ent_id])
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+
+    true_task = [1, 3]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+
+  def test_hoard_gold_and_team(self): # HoardGold, TeamHoardGold
+    agent_gold_goal = 10
+    team_gold_goal = 30
+    test_tasks = [ # (Predicate, Team), the reward is 1 by default
+      (bp.HoardGold(Group([1]), agent_gold_goal), ALL_AGENT), # True
+      (bp.HoardGold(Group([4,5,6]), agent_gold_goal), ALL_AGENT), # False
+      (bp.HoardGold(Group([1,3,5]), team_gold_goal), ALL_AGENT), # True
+      (bp.HoardGold(Group([2,4,6]), team_gold_goal), ALL_AGENT)] # False
+
+    env = self._get_taskenv(test_tasks)
+
+    # give gold to agents 1-3
+    gold_struck = [1, 2, 3]
+    for ent_id in gold_struck:
+      env.realm.players[ent_id].gold.update(ent_id * 10)
+    env.obs = env._compute_observations()
+
+    _, _, _, infos = env.step({})
+
+    true_task = [0, 2]
+    self._check_result(env, test_tasks, infos, true_task)
+    g = sum(env.realm.players[eid].gold.val for eid in Group([2,4,6]).agents)
+    self._check_progress(test_tasks[3], infos, g / team_gold_goal)
+
+    # DONE
+
+  def test_exchange_gold_predicates(self): # Earn Gold, Spend Gold, Make Profit
+    gold_goal = 10
+    test_tasks = [
+      (bp.EarnGold(Group([1,2]), gold_goal), ALL_AGENT), # True
+      (bp.EarnGold(Group([2,4]), gold_goal), ALL_AGENT), # False
+      (bp.SpendGold(Group([1]), 5), ALL_AGENT), # False -> True
+      (bp.SpendGold(Group([1]), 6), ALL_AGENT), # False,
+      (bp.MakeProfit(Group([1,2]), 5), ALL_AGENT), # True,
+      (bp.MakeProfit(Group([1]), 5), ALL_AGENT) # True -> False
+    ]
+
+    env = self._get_taskenv(test_tasks)
+    players = env.realm.players
+
+    # 8 gold earned for agent 1
+    # 2 for agent 2
+    env.realm.event_log.record(EventCode.EARN_GOLD, players[1], amount = 5)
+    env.realm.event_log.record(EventCode.EARN_GOLD, players[1], amount = 3)
+    env.realm.event_log.record(EventCode.EARN_GOLD, players[2], amount = 2)
+
+    env.obs = env._compute_observations()
+    _, _, _, infos = env.step({})
+
+    true_task = [0,4,5]
+    self._check_result(env, test_tasks, infos, true_task)
+    self._check_progress(test_tasks[1], infos, 2 / gold_goal)
+
+    env.realm.event_log.record(EventCode.BUY_ITEM, players[1],
+                               item=Item.Ration(env.realm,1),
+                               price=5)
+    env.obs = env._compute_observations()
+    _, _, _, infos = env.step({})
+
+    true_task = [0,2,4]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+
+  def test_count_event(self): # CountEvent
+    test_tasks = [
+      (bp.CountEvent(Group([1]),"EAT_FOOD",1), ALL_AGENT), # True
+      (bp.CountEvent(Group([1]),"EAT_FOOD",2), ALL_AGENT), # False
+      (bp.CountEvent(Group([1]),"DRINK_WATER",1), ALL_AGENT), # False
+      (bp.CountEvent(Group([1,2]),"GIVE_GOLD",1), ALL_AGENT) # True
+    ]
+
+    # 1 Drinks water once
+    # 2 Gives gold once
+    env = self._get_taskenv(test_tasks)
+    players = env.realm.players
+    env.realm.event_log.record(EventCode.EAT_FOOD, players[1])
+    env.realm.event_log.record(EventCode.GIVE_GOLD, players[2])
+    env.obs = env._compute_observations()
+    _, _, _, infos = env.step({})
+
+    true_task = [0,3]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+
+  def test_score_hit(self): # ScoreHit
+    test_tasks = [
+      (bp.ScoreHit(Group([1]), Skill.Mage, 2), ALL_AGENT), # False -> True
+      (bp.ScoreHit(Group([1]), Skill.Melee, 1), ALL_AGENT) # True
+    ]
+    env = self._get_taskenv(test_tasks)
+    players = env.realm.players
+
+    env.realm.event_log.record(EventCode.SCORE_HIT,
+                               players[1],
+                               combat_style = Skill.Mage,
+                               damage=1)
+    env.realm.event_log.record(EventCode.SCORE_HIT,
+                               players[1],
+                               combat_style = Skill.Melee,
+                               damage=1)
+
+    env.obs = env._compute_observations()
+    _, _, _, infos = env.step({})
+
+    true_task = [1]
+    self._check_result(env, test_tasks, infos, true_task)
+    self._check_progress(test_tasks[0], infos, 0.5)
+
+    env.realm.event_log.record(EventCode.SCORE_HIT,
+                               players[1],
+                               combat_style = Skill.Mage,
+                               damage=1)
+    env.realm.event_log.record(EventCode.SCORE_HIT,
+                               players[1],
+                               combat_style = Skill.Melee,
+                               damage=1)
+
+    env.obs = env._compute_observations()
+    _, _, _, infos = env.step({})
+
+    true_task = [0,1]
+    self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+  
+  def test_item_event_predicates(self): # Consume, Harvest, List, Buy
+    for predicate, event_type in [(bp.ConsumeItem, 'CONSUME_ITEM'),
+                                  (bp.HarvestItem, 'HARVEST_ITEM'),
+                                  (bp.ListItem, 'LIST_ITEM'),
+                                  (bp.BuyItem, 'BUY_ITEM')]:
+      id_ = getattr(EventCode, event_type)
+      lvl = random.randint(5,10)
+      quantity = random.randint(5,10)
+      true_item = Item.Ration
+      false_item = Item.Poultice
+      test_tasks = [
+        (predicate(Group([1,3,5]), true_item, lvl, quantity), ALL_AGENT), # True
+        (predicate(Group([2]), true_item, lvl, quantity), ALL_AGENT), # False
+        (predicate(Group([4]), true_item, lvl, quantity), ALL_AGENT), # False
+        (predicate(Group([6]), true_item, lvl, quantity), ALL_AGENT) # False
+      ]
+
+      env = self._get_taskenv(test_tasks)
+      players = env.realm.players
+      # True case: split the required items between 3 and 5
+      for player in (1,3):
+        for _ in range(quantity // 2 + 1):
+          env.realm.event_log.record(id_,
+                                players[player],
+                                price=1,
+                                item=true_item(env.realm,
+                                               lvl+random.randint(0,3)))
+
+      # False case 1: Quantity
+      for _ in range(quantity-1):
+          env.realm.event_log.record(id_,
+                                players[2],
+                                price=1,
+                                item=true_item(env.realm, lvl))
+
+      # False case 2: Type
+      for _ in range(quantity+1):
+          env.realm.event_log.record(id_,
+                                players[4],
+                                price=1,
+                                item=false_item(env.realm, lvl))
+      # False case 3: Level
+      for _ in range(quantity+1):
+          env.realm.event_log.record(id_,
+                                players[4],
+                                price=1,
+                                item=true_item(env.realm,
+                                               random.randint(0,lvl-1)))
+      env.obs = env._compute_observations()
+      _, _, _, infos = env.step({})
+      true_task = [0]
+      self._check_result(env, test_tasks, infos, true_task)
+
+    # DONE
+if __name__ == '__main__':
+  unittest.main()
diff --git a/tests/task/test_task_api.py b/tests/task/test_task_api.py
new file mode 100644
index 000000000..cd79a8316
--- /dev/null
+++ b/tests/task/test_task_api.py
@@ -0,0 +1,171 @@
+# pylint: disable=import-error, unused-argument
+import logging
+import unittest
+from tests.testhelpers import ScriptedAgentTestConfig
+import numpy as np
+
+import nmmo
+from nmmo.core.env import Env
+from nmmo.task.task_api import define_predicate, define_task
+from nmmo.task.group import Group
+from nmmo.task.team_helper import TeamHelper
+from nmmo.task.constraint import InvalidConstraint, ScalarConstraint
+from nmmo.task.base_predicates import TickGE, CanSeeGroup
+
+from nmmo.systems import item as Item
+from nmmo.io import action as Action
+
+@define_predicate
+def Success(gs, subject: Group):
+  return True
+
+@define_predicate
+def Failure(gs, subject: Group):
+  return False
+
+@define_task
+def Fake(gs, subject, a,b,c):
+  return False
+
+class MockGameState():
+  def __init__(self):
+    # pylint: disable=super-init-not-called
+    self.config = nmmo.config.Default()
+    self.cache_result = {}
+    self.get_subject_view = lambda _: None
+class TestTaskAPI(unittest.TestCase):
+
+  def test_operators(self):
+    # pylint: disable=unsupported-binary-operation,invalid-unary-operand-type
+
+    mock_gs = MockGameState()
+    SUCCESS = Success(Group([0]))
+    FAILURE = Failure(Group([0]))
+    # AND (&), OR (|), NOT (~), IMPLY (>>)
+    task1 = SUCCESS & FAILURE
+    self.assertFalse(task1(mock_gs))
+
+    task2 = SUCCESS | FAILURE | SUCCESS
+    self.assertTrue(task2(mock_gs))
+
+    task3 = SUCCESS & ~ FAILURE & SUCCESS
+    self.assertTrue(task3(mock_gs))
+
+    task4 = SUCCESS >> SUCCESS
+    self.assertTrue(task4(mock_gs))
+
+    task5 = SUCCESS >> ~ SUCCESS
+    self.assertFalse(task5(mock_gs))
+
+    task6 = (FAILURE >> FAILURE) & SUCCESS
+    self.assertTrue(task6(mock_gs))
+
+    task7 = SUCCESS + SUCCESS
+    self.assertEqual(task7(mock_gs),2)
+
+    task8 = SUCCESS * 3
+    self.assertEqual(task8(mock_gs),3)
+
+    self.assertEqual(task6.name, "(PAND_(IMPLY_(Failure_(0,))_(Failure_(0,)))_(Success_(0,)))")
+
+  def test_team_assignment(self):
+    team =  Group([1, 2, 8, 9], "TeamFoo")
+
+    self.assertEqual(team.name, 'TeamFoo')
+    self.assertEqual(team[2].name, "TeamFoo.2")
+    self.assertEqual(team[2], (8,))
+
+    # don't allow member of one-member team
+    self.assertEqual(team[2][0].name, team[2].name)
+
+  def test_task_name(self):
+    SUCCESS = Success(Group([0]))
+    FAILURE = Failure(Group([0]))
+    fake_task = Fake(Group([2]), 1, Item.Hat, Action.Melee)
+    combination = (SUCCESS & ~ (FAILURE | fake_task)) | (FAILURE >> fake_task)
+    self.assertEqual(combination.name,
+      "(POR_(PAND_(Success_(0,))_(PNOT_(POR_(Failure_(0,))_(Fake_(2,)_1_Hat_Melee))))_\
+(IMPLY_(Failure_(0,))_(Fake_(2,)_1_Hat_Melee)))")
+
+  def test_constraint(self):
+    mock_gs = MockGameState()
+    good = Success(Group([0]))
+    bad = Success(Group([99999]))
+    good(mock_gs)
+    self.assertRaises(InvalidConstraint,lambda: bad(mock_gs))
+
+    scalar = ScalarConstraint(low=-10,high=10)
+    for _ in range(10):
+      self.assertTrue(scalar.sample(mock_gs.config)<10)
+      self.assertTrue(scalar.sample(mock_gs.config)>=-10)
+
+    bad = TickGE(Group([0]), -1)
+    self.assertRaises(InvalidConstraint, lambda: bad(mock_gs))
+
+  def test_sample_task(self):
+    task = CanSeeGroup() & TickGE()
+    self.assertEqual(task.name,
+                     "(PAND_(CanSeeGroup_subject:GroupConstraint_target:GroupConstraint)_\
+(TickGE_subject:GroupConstraint_num_tick:ScalarConstraint))")
+    config = nmmo.config.Default()
+    TickGE().sample(config)
+    task.sample(config).name
+
+    # DONE
+
+  def test_completed_tasks_in_info(self):
+    config = ScriptedAgentTestConfig()
+    env = Env(config)
+    team_helper = TeamHelper.generate_from_config(config)
+    fake_task = Fake(Group([3]), 1, Item.Hat, Action.Melee)
+    task_assignment = \
+      [(Success(Group([1])),2),
+       Failure(Group([1])),
+       Success(Group([1])) * -1,
+       3 * Success(Group([1])),
+       Success(team_helper.own_team(2)),
+       fake_task
+      ]
+    env.change_task(task_assignment)
+    _, _, _, infos = env.step({})
+
+    # agent 1: task1 is always True
+    self.assertEqual(infos[1]['task'][Success(Group([1])).name], 1.0)
+    self.assertEqual(infos[1]['task'][(Success(Group([1])) * -1).name], -1.0)
+    self.assertEqual(infos[1]['task'][(3*Success(Group([1]))).name], 3.0)
+
+    # agent 2 should have been assigned Success but not Fake()
+    self.assertEqual(infos[2]['task'][Success(team_helper.own_team(2)).name], 1)
+    self.assertTrue(fake_task.name not in infos[2]['task'])
+
+    # agent 3 should have been assigned Fake(), which is always False (0)
+    self.assertEqual(infos[3]['task'][fake_task.name], 0)
+
+    # all agents in the same team with agent 2 have SUCCESS
+    # other agents don't have any tasks assigned
+    group_name = Success(team_helper.own_team(2)).name
+    for ent_id in range(4, config.PLAYER_N+1):
+      if Group([ent_id]) in team_helper.own_team(2):
+        self.assertEqual(infos[ent_id]['task'][group_name], 1)
+      else:
+        self.assertEqual(infos[ent_id]['task'], {})
+
+    # DONE
+
+  def test_task_embedding(self):
+    env = Env()
+    obs = env.reset()
+    self.assertRaises(KeyError, lambda: obs[1]['Task'])
+
+    task = [Success([1,2])]
+    env.change_task(task,
+                    task_encoding={1:np.array([1,2,3,4])},
+                    embedding_size=4)
+    obs = env.reset()
+    self.assertTrue(all(obs[1]['Task']==np.array([1,2,3,4])))
+    self.assertTrue(all(obs[2]['Task']==np.array([0,0,0,0])))
+
+    # DONE
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/tests/test_task.py b/tests/test_task.py
deleted file mode 100644
index f8c61040c..000000000
--- a/tests/test_task.py
+++ /dev/null
@@ -1,121 +0,0 @@
-# pylint: disable=redefined-outer-name,super-init-not-called
-
-import logging
-import unittest
-
-import nmmo
-from nmmo.lib import task
-from nmmo.systems import achievement
-from nmmo.core.realm import Realm
-from nmmo.entity.entity import Entity
-from scripted.baselines import Sleeper
-
-
-class Success(task.Task):
-  def completed(self, realm: Realm, entity: Entity) -> bool:
-    return True
-
-class Failure(task.Task):
-  def completed(self, realm: Realm, entity: Entity) -> bool:
-    return False
-
-class FakeTask(task.TargetTask):
-  def __init__(self, target: task.TaskTarget, param1: int, param2: float) -> None:
-    super().__init__(target)
-    self._param1 = param1
-    self._param2 = param2
-
-  def completed(self, realm: Realm, entity: Entity) -> bool:
-    return False
-
-  def description(self):
-    return [super().description(), self._param1, self._param2]
-
-# pylint: disable
-class MockRealm(Realm):
-  def __init__(self):
-    pass
-
-class MockEntity(Entity):
-  def __init__(self):
-    pass
-
-realm = MockRealm()
-entity = MockEntity()
-
-class TestTasks(unittest.TestCase):
-
-  def test_operators(self):
-    self.assertFalse(task.AND(Success(), Failure(), Success()).completed(realm, entity))
-    self.assertTrue(task.OR(Success(), Failure(), Success()).completed(realm, entity))
-    self.assertTrue(task.AND(Success(), task.NOT(Failure()), Success()).completed(realm, entity))
-
-  def test_descriptions(self):
-    self.assertEqual(
-      task.AND(Success(),
-      task.NOT(task.OR(Success(),
-                        FakeTask(task.TaskTarget("t1", []), 123, 3.45)))).description(),
-      ['AND', 'Success', ['NOT', ['OR', 'Success', [['FakeTask', 't1'], 123, 3.45]]]]
-    )
-
-  def test_team_helper(self):
-    team_helper = task.TeamHelper(range(1, 101), 5)
-
-    self.assertSequenceEqual(team_helper.own_team(17).agents(), range(1, 21))
-    self.assertSequenceEqual(team_helper.own_team(84).agents(), range(81, 101))
-
-    self.assertSequenceEqual(team_helper.left_team(84).agents(), range(61, 81))
-    self.assertSequenceEqual(team_helper.right_team(84).agents(), range(1, 21))
-
-    self.assertSequenceEqual(team_helper.left_team(17).agents(), range(81, 101))
-    self.assertSequenceEqual(team_helper.right_team(17).agents(), range(21, 41))
-
-    self.assertSequenceEqual(team_helper.all().agents(), range(1, 101))
-
-  def test_task_target(self):
-    task_target = task.TaskTarget("Foo", [1, 2, 8, 9])
-
-    self.assertEqual(task_target.member(2).description(), "Foo.2")
-    self.assertEqual(task_target.member(2).agents(), [8])
-
-  def test_sample(self):
-    sampler = task.TaskSampler()
-
-    sampler.add_task_spec(Success)
-    sampler.add_task_spec(Failure)
-    sampler.add_task_spec(FakeTask, [
-      [task.TaskTarget("t1", []), task.TaskTarget("t2", [])],
-      [1, 5, 10],
-      [0.1, 0.2, 0.3, 0.4]
-    ])
-
-    sampler.sample(max_clauses=5, max_clause_size=5, not_p=0.5)
-
-  def test_default_sampler(self):
-    team_helper = task.TeamHelper(range(1, 101), 5)
-    sampler = task.TaskSampler.create_default_task_sampler(team_helper, 10)
-
-    sampler.sample(max_clauses=5, max_clause_size=5, not_p=0.5)
-
-  def test_completed_tasks_in_info(self):
-    config = nmmo.config.Default()
-    config.PLAYERS = [Sleeper]
-    config.TASKS = [
-      achievement.Achievement(Success(), 10),
-      achievement.Achievement(Failure(), 100)
-    ]
-
-    env = nmmo.Env(config)
-
-    env.reset()
-    _, _, _, infos = env.step({})
-    logging.info(infos)
-    self.assertEqual(infos[1][Success().to_string()], 10)
-    self.assertEqual(infos[1][Failure().to_string()], 0)
-
-    _, _, _, infos = env.step({})
-    self.assertEqual(infos[1][Success().to_string()], 0)
-    self.assertEqual(infos[1][Failure().to_string()], 0)
-
-if __name__ == '__main__':
-  unittest.main()