diff --git a/nmmo/core/config.py b/nmmo/core/config.py index 49f46e2e..4e4fad17 100644 --- a/nmmo/core/config.py +++ b/nmmo/core/config.py @@ -521,6 +521,9 @@ class Item: ITEM_INVENTORY_CAPACITY = 12 '''Number of inventory spaces''' + ITEM_GIVE_TO_FRIENDLY = True + '''Whether agents with the same population index can give gold/item to each other''' + @property def INVENTORY_N_OBS(self): '''Number of distinct item observations''' diff --git a/nmmo/core/env.py b/nmmo/core/env.py index 3bc9dbe2..b37822fb 100644 --- a/nmmo/core/env.py +++ b/nmmo/core/env.py @@ -35,12 +35,11 @@ def __init__(self, self.obs = None self.possible_agents = list(range(1, config.PLAYER_N + 1)) - self._dead_agents = set() + self._dead_agents = OrderedSet() self.scripted_agents = OrderedSet() # pylint: disable=method-cache-max-size-none @functools.lru_cache(maxsize=None) - # CHECK ME: Do we need the agent parameter here? def observation_space(self, agent: int): '''Neural MMO Observation Space @@ -79,7 +78,6 @@ def _init_random(self, seed): random.seed(seed) @functools.lru_cache(maxsize=None) - # CHECK ME: Do we need the agent parameter here? def action_space(self, agent): '''Neural MMO Action Space @@ -133,7 +131,7 @@ def reset(self, map_id=None, seed=None, options=None): self._init_random(seed) self.realm.reset(map_id) - self._dead_agents = set() + self._dead_agents = OrderedSet() # check if there are scripted agents for eid, ent in self.realm.players.items(): diff --git a/nmmo/core/observation.py b/nmmo/core/observation.py index ef8e7203..16c84903 100644 --- a/nmmo/core/observation.py +++ b/nmmo/core/observation.py @@ -32,11 +32,9 @@ def __init__(self, values, id_col): self.inv_type = self.values[:,ItemState.State.attr_name_to_col["type_id"]] self.inv_level = self.values[:,ItemState.State.attr_name_to_col["level"]] - def sig(self, itm_type, level): - if (itm_type in self.inv_type) and (level in self.inv_level): - return np.nonzero((self.inv_type == itm_type) & (self.inv_level == level))[0][0] - - return None + def sig(self, item: item_system.Item, level: int): + idx = np.nonzero((self.inv_type == item.ITEM_TYPE_ID) & (self.inv_level == level))[0] + return idx[0] if len(idx) else None class Observation: @@ -148,15 +146,26 @@ def _make_action_targets(self): masks[action.Use] = { action.InventoryItem: self._make_use_mask() } + masks[action.Give] = { + action.InventoryItem: self._make_sell_mask(), + action.Target: self._make_give_target_mask() + } + masks[action.Destroy] = { + action.InventoryItem: self._make_destroy_item_mask() + } if self.config.EXCHANGE_SYSTEM_ENABLED: masks[action.Sell] = { action.InventoryItem: self._make_sell_mask(), - action.Price: None # allow any integer + action.Price: None # should allow any integer > 0 } masks[action.Buy] = { action.MarketItem: self._make_buy_mask() } + masks[action.GiveGold] = { + action.Target: self._make_give_target_mask(), + action.Price: None # reusing Price, allow any integer > 0 + } if self.config.COMMUNICATION_SYSTEM_ENABLED: masks[action.Comm] = { @@ -189,20 +198,28 @@ def _make_attack_mask(self): EntityState.State.attr_name_to_col["col"]]] within_range = utils.linf(entities_pos, (agent.row, agent.col)) <= attack_range + immunity = self.config.COMBAT_SPAWN_IMMUNITY + if 0 < immunity < agent.time_alive: + # ids > 0 equals entity.is_player + spawn_immunity = (self.entities.ids > 0) & \ + (self.entities.values[:,EntityState.State.attr_name_to_col["time_alive"]] < immunity) + else: + spawn_immunity = np.ones(self.entities.len, dtype=np.int8) + if not self.config.COMBAT_FRIENDLY_FIRE: population = self.entities.values[:,EntityState.State.attr_name_to_col["population_id"]] no_friendly_fire = population != agent.population_id # this automatically masks self else: - # allow friendly fire but self + # allow friendly fire but no self shooting no_friendly_fire = np.ones(self.entities.len, dtype=np.int8) no_friendly_fire[self.entities.index(agent.id)] = 0 # mask self - return np.concatenate([within_range & no_friendly_fire, + return np.concatenate([within_range & no_friendly_fire & spawn_immunity, np.zeros(self.config.PLAYER_N_OBS - self.entities.len, dtype=np.int8)]) def _make_use_mask(self): # empty inventory -- nothing to use - if self.inventory.len == 0: + if not (self.config.ITEM_SYSTEM_ENABLED and self.inventory.len > 0): return np.zeros(self.config.INVENTORY_N_OBS, dtype=np.int8) item_skill = self._item_skill() @@ -247,9 +264,35 @@ def _item_skill(self): item_system.Poultice.ITEM_TYPE_ID: level } + def _make_destroy_item_mask(self): + # empty inventory -- nothing to destroy + if not (self.config.ITEM_SYSTEM_ENABLED and self.inventory.len > 0): + return np.zeros(self.config.INVENTORY_N_OBS, dtype=np.int8) + + not_equipped = self.inventory.values[:,ItemState.State.attr_name_to_col["equipped"]] == 0 + + # not equipped items in the inventory can be destroyed + return np.concatenate([not_equipped, + np.zeros(self.config.INVENTORY_N_OBS - self.inventory.len, dtype=np.int8)]) + + def _make_give_target_mask(self): + # empty inventory -- nothing to give + if not (self.config.ITEM_SYSTEM_ENABLED and self.inventory.len > 0): + return np.zeros(self.config.PLAYER_N_OBS, dtype=np.int8) + + agent = self.agent() + entities_pos = self.entities.values[:, [EntityState.State.attr_name_to_col["row"], + EntityState.State.attr_name_to_col["col"]]] + same_tile = utils.linf(entities_pos, (agent.row, agent.col)) == 0 + same_team_not_me = (self.entities.ids != agent.id) & (agent.population_id == \ + self.entities.values[:, EntityState.State.attr_name_to_col["population_id"]]) + + return np.concatenate([same_tile & same_team_not_me, + np.zeros(self.config.PLAYER_N_OBS - self.entities.len, dtype=np.int8)]) + def _make_sell_mask(self): # empty inventory -- nothing to sell - if self.inventory.len == 0: + if not (self.config.EXCHANGE_SYSTEM_ENABLED and self.inventory.len > 0): return np.zeros(self.config.INVENTORY_N_OBS, dtype=np.int8) not_equipped = self.inventory.values[:,ItemState.State.attr_name_to_col["equipped"]] == 0 @@ -259,10 +302,14 @@ def _make_sell_mask(self): np.zeros(self.config.INVENTORY_N_OBS - self.inventory.len, dtype=np.int8)]) def _make_buy_mask(self): + if not self.config.EXCHANGE_SYSTEM_ENABLED: + return np.zeros(self.config.MARKET_N_OBS, dtype=np.int8) + market_flt = np.ones(self.market.len, dtype=np.int8) full_inventory = self.inventory.len >= self.config.ITEM_INVENTORY_CAPACITY # if the inventory is full, one can only buy existing ammo stack + # otherwise, one can buy anything owned by other, having enough money if full_inventory: exist_ammo_listings = self._existing_ammo_listings() if not np.any(exist_ammo_listings): @@ -273,9 +320,8 @@ def _make_buy_mask(self): market_items = self.market.values enough_gold = market_items[:,ItemState.State.attr_name_to_col["listed_price"]] <= agent.gold not_mine = market_items[:,ItemState.State.attr_name_to_col["owner_id"]] != self.agent_id - not_equipped = market_items[:,ItemState.State.attr_name_to_col["equipped"]] == 0 - return np.concatenate([market_flt & enough_gold & not_mine & not_equipped, + return np.concatenate([market_flt & enough_gold & not_mine, np.zeros(self.config.MARKET_N_OBS - self.market.len, dtype=np.int8)]) def _existing_ammo_listings(self): @@ -295,7 +341,7 @@ def _existing_ammo_listings(self): if exist_ammo.shape[0] == 0: return np.zeros(self.market.len, dtype=np.int8) - # search the existing ammo stack from the market + # search the existing ammo stack from the market that's not mine type_flt = np.tile( np.array(exist_ammo[:,sig_col[0]]), (self.market.len,1)) level_flt = np.tile( np.array(exist_ammo[:,sig_col[1]]), (self.market.len,1)) item_type = np.tile( np.transpose(np.atleast_2d(self.market.values[:,sig_col[0]])), diff --git a/nmmo/core/realm.py b/nmmo/core/realm.py index 5c41eb4c..3dbf210d 100644 --- a/nmmo/core/realm.py +++ b/nmmo/core/realm.py @@ -14,7 +14,7 @@ from nmmo.core.tile import TileState from nmmo.entity.entity import EntityState from nmmo.entity.entity_manager import NPCManager, PlayerManager -from nmmo.io.action import Action +from nmmo.io.action import Action, Buy from nmmo.lib.datastore.numpy_datastore import NumpyDatastore from nmmo.systems.exchange import Exchange from nmmo.systems.item import Item, ItemState @@ -150,14 +150,27 @@ def step(self, actions): self.players.update(actions) self.npcs.update(npc_actions) - # Execute actions + # Execute actions -- CHECK ME the below priority + # - 10: Use - equip ammo, restore HP, etc. + # - 20: Buy - exchange while sellers, items, buyers are all intact + # - 30: Give, GiveGold - transfer while both are alive and at the same tile + # - 40: Destroy - use with SELL/GIVE, if not gone, destroy and recover space + # - 50: Attack + # - 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. - ent_id, (atn, args) = merged[priority][0] + # will always go first. --> ONLY SHUFFLE BUY + if priority == Buy.priority: + np.random.shuffle(merged[priority]) + + # CHECK ME: do we need this line? + # ent_id, (atn, args) = merged[priority][0] for ent_id, (atn, args) in merged[priority]: ent = self.entity(ent_id) - atn.call(self, ent, *args) + if ent.alive: + atn.call(self, ent, *args) dead = self.players.cull() self.npcs.cull() diff --git a/nmmo/entity/entity.py b/nmmo/entity/entity.py index 7e779ce3..3323eaa9 100644 --- a/nmmo/entity/entity.py +++ b/nmmo/entity/entity.py @@ -283,12 +283,16 @@ def receive_damage(self, source, dmg): if self.alive: return True - # if the entity is dead, unlist its items regardless of looting + # at this point, self is dead + if source: + source.history.player_kills += 1 + + # if self is dead, unlist its items from the market regardless of looting if self.config.EXCHANGE_SYSTEM_ENABLED: for item in list(self.inventory.items): self.realm.exchange.unlist_item(item) - # if the entity is dead but no one can loot, destroy its items + # if self is dead but no one can loot, destroy its items if source is None or not source.is_player: # nobody or npcs cannot loot if self.config.ITEM_SYSTEM_ENABLED: for item in list(self.inventory.items): diff --git a/nmmo/entity/player.py b/nmmo/entity/player.py index c9afb4c8..ae56826b 100644 --- a/nmmo/entity/player.py +++ b/nmmo/entity/player.py @@ -59,32 +59,28 @@ def receive_damage(self, source, dmg): if self.immortal: return False + # super().receive_damage returns True if self is alive after taking dmg if super().receive_damage(source, dmg): return True if not self.config.ITEM_SYSTEM_ENABLED: return False - # if self is killed, source receive gold & inventory items - source.gold.increment(self.gold.val) - self.gold.update(0) + # starting from here, source receive gold & inventory items + if self.config.EXCHANGE_SYSTEM_ENABLED: + source.gold.increment(self.gold.val) + self.gold.update(0) # TODO(kywch): make source receive the highest-level items first # because source cannot take it if the inventory is full # Also, destroy the remaining items if the source cannot take those for item in list(self.inventory.items): - if not item.quantity.val: - item.datastore_record.delete() - continue - self.inventory.remove(item) - source.inventory.receive(item) - if not super().receive_damage(source, dmg): - if source: - source.history.player_kills += 1 - return False + # if source doesn't have space, inventory.receive() destroys the item + source.inventory.receive(item) + # CHECK ME: this is an empty function. do we still need this? self.skills.receive_damage(dmg) return False diff --git a/nmmo/io/action.py b/nmmo/io/action.py index b122afa0..1d0ebca5 100644 --- a/nmmo/io/action.py +++ b/nmmo/io/action.py @@ -1,5 +1,7 @@ # pylint: disable=all # TODO(kywch): If edits work, I will make it pass pylint +# also the env in call(env, entity, direction) functions below +# is actually realm. See realm.step() Should be changed. from ordered_set import OrderedSet import numpy as np @@ -8,7 +10,7 @@ from nmmo.lib import utils from nmmo.lib.utils import staticproperty -from nmmo.systems.item import Item +from nmmo.systems.item import Item, Stack class NodeType(Enum): #Tree edges @@ -97,9 +99,9 @@ def edges(cls, config): if config.COMBAT_SYSTEM_ENABLED: edges.append(Attack) if config.ITEM_SYSTEM_ENABLED: - edges += [Use] + edges += [Use, Give, Destroy] if config.EXCHANGE_SYSTEM_ENABLED: - edges += [Buy, Sell] + edges += [Buy, Sell, GiveGold] if config.COMMUNICATION_SYSTEM_ENABLED: edges.append(Comm) return edges @@ -108,9 +110,11 @@ def args(stim, entity, config): raise NotImplementedError class Move(Node): - priority = 1 + priority = 60 nodeType = NodeType.SELECTION def call(env, entity, direction): + assert entity.alive, "Dead entity cannot act" + r, c = entity.pos ent_id = entity.ent_id entity.history.last_pos = (r, c) @@ -164,7 +168,7 @@ class West(Node): class Attack(Node): - priority = 0 + priority = 50 nodeType = NodeType.SELECTION @staticproperty def n(): @@ -200,14 +204,16 @@ def l1(pos, cent): return abs(r - rCent) + abs(c - cCent) def call(env, entity, style, targ): + assert entity.alive, "Dead entity cannot act" + config = env.config - if entity.is_player and not config.COMBAT_SYSTEM_ENABLED: return # Testing a spawn immunity against old agents to avoid spawn camping immunity = config.COMBAT_SPAWN_IMMUNITY - if entity.is_player and targ.is_player and entity.history.time_alive.val > immunity and targ.history.time_alive < immunity: + if entity.is_player and targ.is_player and \ + targ.history.time_alive < immunity < entity.history.time_alive.val: return #Check if self targeted @@ -255,6 +261,8 @@ def N(cls, config): return config.PLAYER_N_OBS def deserialize(realm, entity, index): + # NOTE: index is the entity id + # CHECK ME: should index be renamed to ent_id? return realm.entity(index) def args(stim, entity, config): @@ -304,6 +312,7 @@ def args(stim, entity, config): return stim.exchange.items() def deserialize(realm, entity, index): + # NOTE: index is from the inventory, NOT item id inventory = Item.Query.owned_by(realm.datastore, entity.id.val) if index >= inventory.shape[0]: @@ -313,40 +322,135 @@ def deserialize(realm, entity, index): return realm.items[item_id] class Use(Node): - priority = 3 + priority = 10 @staticproperty def edges(): return [InventoryItem] def call(env, entity, item): + assert entity.alive, "Dead entity cannot act" + assert entity.is_player, "Npcs cannot use an item" + assert item.quantity.val > 0, "Item quantity cannot be 0" # indicates item leak + + if not env.config.ITEM_SYSTEM_ENABLED: + return + if item not in entity.inventory: return + # cannot use listed items or items that have higher level + if item.listed_price.val > 0 or item.level.val > item._level(entity): + return + return item.use(entity) +class Destroy(Node): + priority = 40 + + @staticproperty + def edges(): + return [InventoryItem] + + def call(env, entity, item): + assert entity.alive, "Dead entity cannot act" + assert entity.is_player, "Npcs cannot destroy an item" + assert item.quantity.val > 0, "Item quantity cannot be 0" # indicates item leak + + if not env.config.ITEM_SYSTEM_ENABLED: + return + + if item not in entity.inventory: + return + + if item.equipped.val: # cannot destroy equipped item + return + + # inventory.remove() also unlists the item, if it has been listed + entity.inventory.remove(item) + + return item.destroy() + class Give(Node): - priority = 2 + priority = 30 @staticproperty def edges(): return [InventoryItem, Target] def call(env, entity, item, target): + assert entity.alive, "Dead entity cannot act" + assert entity.is_player, "Npcs cannot give an item" + assert item.quantity.val > 0, "Item quantity cannot be 0" # indicates item leak + + config = env.config + if not config.ITEM_SYSTEM_ENABLED: + return + + if not (target.is_player and target.alive): + return + if item not in entity.inventory: return - if not target.is_player: + # cannot give the equipped or listed item + if item.equipped.val or item.listed_price.val: + return + + if not (config.ITEM_GIVE_TO_FRIENDLY and + entity.population_id == target.population_id and # the same team + entity.ent_id != target.ent_id and # but not self + utils.linf(entity.pos, target.pos) == 0): # the same tile return if not target.inventory.space: + # receiver inventory is full - see if it has an ammo stack with the same sig + if isinstance(item, Stack): + if not target.inventory.has_stack(item.signature): + # no ammo stack with the same signature, so cannot give + return + else: # no space, and item is not ammo stack, so cannot give + return + + entity.inventory.remove(item) + return target.inventory.receive(item) + + +class GiveGold(Node): + priority = 30 + + @staticproperty + def edges(): + # CHECK ME: for now using Price to indicate the gold amount to give + return [Target, Price] + + def call(env, entity, target, amount): + assert entity.alive, "Dead entity cannot act" + assert entity.is_player, "Npcs cannot give gold" + + config = env.config + if not config.EXCHANGE_SYSTEM_ENABLED: + return + + if not (target.is_player and target.alive): + return + + if not (config.ITEM_GIVE_TO_FRIENDLY and + entity.population_id == target.population_id and # the same team + entity.ent_id != target.ent_id and # but not self + utils.linf(entity.pos, target.pos) == 0): # the same tile return - entity.inventory.remove(item, quantity=1) - item = type(item)(env, item.level.val) - target.inventory.receive(item) + if type(amount) != int: + amount = amount.val + + if not (amount > 0 and entity.gold.val > 0): # no gold to give + return + + amount = min(amount, entity.gold.val) - return True + entity.gold.decrement(amount) + return target.gold.increment(amount) class MarketItem(Node): @@ -361,6 +465,7 @@ def args(stim, entity, config): return stim.exchange.items() def deserialize(realm, entity, index): + # NOTE: index is from the market, NOT item id market = Item.Query.for_sale(realm.datastore) if index >= market.shape[0]: @@ -370,7 +475,7 @@ def deserialize(realm, entity, index): return realm.items[item_id] class Buy(Node): - priority = 4 + priority = 20 argType = Fixed @staticproperty @@ -378,17 +483,34 @@ def edges(): return [MarketItem] def call(env, entity, item): - #Do not process exchange actions on death tick - if not entity.alive: + assert entity.alive, "Dead entity cannot act" + assert entity.is_player, "Npcs cannot buy an item" + assert item.quantity.val > 0, "Item quantity cannot be 0" # indicates item leak + assert item.equipped.val == 0, 'Listed item must not be equipped' + + if not env.config.EXCHANGE_SYSTEM_ENABLED: return - if not entity.inventory.space: + if entity.gold.val < item.listed_price.val: # not enough money + return + + if entity.ent_id == item.owner_id.val: # cannot buy own item return + if not entity.inventory.space: + # buyer inventory is full - see if it has an ammo stack with the same sig + if isinstance(item, Stack): + if not entity.inventory.has_stack(item.signature): + # no ammo stack with the same signature, so cannot give + return + else: # no space, and item is not ammo stack, so cannot give + return + + # one can try to buy, but the listing might have gone (perhaps bought by other) return env.exchange.buy(entity, item) class Sell(Node): - priority = 4 + priority = 70 argType = Fixed @staticproperty @@ -396,19 +518,30 @@ def edges(): return [InventoryItem, Price] def call(env, entity, item, price): - #Do not process exchange actions on death tick - if not entity.alive: + assert entity.alive, "Dead entity cannot act" + assert entity.is_player, "Npcs cannot sell an item" + assert item.quantity.val > 0, "Item quantity cannot be 0" # indicates item leak + + if not env.config.EXCHANGE_SYSTEM_ENABLED: return # TODO: Find a better way to check this # Should only occur when item is used on same tick # Otherwise should not be possible + # >> This should involve env._validate_actions, and perhaps action priotities if item not in entity.inventory: return + # cannot sell the equipped or listed item + if item.equipped.val or item.listed_price.val: + return + if type(price) != int: price = price.val + if not (price > 0): + return + return env.exchange.sell(entity, item, price, env.tick) def init_discrete(values): @@ -424,7 +557,7 @@ class Price(Node): @classmethod def init(cls, config): - Price.classes = init_discrete(list(range(100))) + Price.classes = init_discrete(range(1, 101)) # gold should be > 0 @staticproperty def edges(): @@ -449,7 +582,7 @@ def args(stim, entity, config): class Comm(Node): argType = Fixed - priority = 0 + priority = 99 @staticproperty def edges(): diff --git a/nmmo/systems/exchange.py b/nmmo/systems/exchange.py index c8fc51a9..48aa10fb 100644 --- a/nmmo/systems/exchange.py +++ b/nmmo/systems/exchange.py @@ -4,7 +4,7 @@ from typing import Dict -from nmmo.systems.item import Item +from nmmo.systems.item import Item, Stack """ The Exchange class is a simulation of an in-game item exchange. @@ -93,34 +93,42 @@ def sell(self, seller, item: Item, price: int, tick: int): item, object), f'{item} for sale is not an Item instance' assert item in seller.inventory, f'{item} for sale is not in {seller} inventory' assert item.quantity.val > 0, f'{item} for sale has quantity {item.quantity.val}' + assert item.listed_price.val == 0, 'Item is already listed' + assert item.equipped.val == 0, 'Item has been equiped so cannot be listed' + assert price > 0, 'Price must be larger than 0' self._list_item(item, seller, price, tick) + self._realm.log_milestone(f'Sell_{item.__class__.__name__}', item.level.val, f'EXCHANGE: Offered level {item.level.val} {item.__class__.__name__} for {price} gold', tags={"player_id": seller.ent_id}) def buy(self, buyer, item: Item): assert item.quantity.val > 0, f'{item} purchase has quantity {item.quantity.val}' + assert item.equipped.val == 0, 'Listed item must not be equipped' + assert buyer.gold.val >= item.listed_price.val, 'Buyer does not have enough gold' + assert buyer.ent_id != item.owner_id.val, 'One cannot buy their own items' - # TODO: Handle ammo stacks - # i.e., if the item signature matches, the bought item should not occupy space if not buyer.inventory.space: - return + if isinstance(item, Stack): + if not buyer.inventory.has_stack(item.signature): + # no ammo stack with the same signature, so cannot buy + return + else: # no space, and item is not ammo stack, so cannot buy + return # item is not in the listing (perhaps bought by other) if item.id.val not in self._item_listings: return listing = self._item_listings[item.id.val] - - if not buyer.gold.val >= item.listed_price.val: - return + price = item.listed_price.val self.unlist_item(item) listing.seller.inventory.remove(item) buyer.inventory.receive(item) - buyer.gold.decrement(item.listed_price.val) - listing.seller.gold.increment(item.listed_price.val) + buyer.gold.decrement(price) + listing.seller.gold.increment(price) # TODO(kywch): fix logs #self._realm.log_milestone(f'Buy_{item.__name__}', item.level.val) diff --git a/nmmo/systems/inventory.py b/nmmo/systems/inventory.py index feb4d7d3..4048ac9a 100644 --- a/nmmo/systems/inventory.py +++ b/nmmo/systems/inventory.py @@ -110,6 +110,9 @@ def __init__(self, realm, entity): def space(self): return self.capacity - len(self.items) + def has_stack(self, signature: Tuple) -> bool: + return signature in self._item_stacks + def packet(self): item_packet = [] if self.config.ITEM_SYSTEM_ENABLED: @@ -132,7 +135,7 @@ def receive(self, item: Item.Item): if isinstance(item, Item.Stack): signature = item.signature - if signature in self._item_stacks: + if self.has_stack(signature): stack = self._item_stacks[signature] assert item.level.val == stack.level.val, f'{item} stack level mismatch' stack.quantity.increment(item.quantity.val) @@ -170,7 +173,7 @@ def remove(self, item, quantity=None): if isinstance(item, Item.Stack): signature = item.signature - assert item.signature in self._item_stacks, f'{item} stack to remove not in inventory' + assert self.has_stack(item.signature), f'{item} stack to remove not in inventory' stack = self._item_stacks[signature] if quantity is None or stack.quantity.val == quantity: diff --git a/nmmo/systems/item.py b/nmmo/systems/item.py index 24734554..54b0ce1d 100644 --- a/nmmo/systems/item.py +++ b/nmmo/systems/item.py @@ -188,11 +188,9 @@ def _slot(self, entity): raise NotImplementedError def use(self, entity): - if self.listed_price > 0: # cannot use if listed for sale - return - - if self._level(entity) < self.level.val: - return + assert self in entity.inventory, "Item is not in entity's inventory" + assert self.listed_price == 0, "Listed item cannot be used" + assert self._level(entity) >= self.level.val, "Entity's level is not sufficient to use the item" if self.equipped.val: self.unequip(self._slot(entity)) @@ -368,11 +366,9 @@ def damage(self): # so each item takes 1 inventory space class Consumable(Item): def use(self, entity) -> bool: - if self.listed_price > 0: # cannot use if listed for sale - return False - - if self._level(entity) < self.level.val: - return False + assert self in entity.inventory, "Item is not in entity's inventory" + assert self.listed_price == 0, "Listed item cannot be used" + assert self._level(entity) >= self.level.val, "Entity's level is not sufficient to use the item" self.realm.log_milestone( f'Consumed_{self.__class__.__name__}', self.level.val, diff --git a/tests/action/test_ammo_use.py b/tests/action/test_ammo_use.py index 5c626110..ef800a5e 100644 --- a/tests/action/test_ammo_use.py +++ b/tests/action/test_ammo_use.py @@ -2,153 +2,40 @@ import logging # pylint: disable=import-error -from testhelpers import ScriptedAgentTestEnv, ScriptedAgentTestConfig +from testhelpers import ScriptedTestTemplate -from scripted import baselines from nmmo.io import action from nmmo.systems import item as Item from nmmo.systems.item import ItemState -from nmmo.entity.entity import EntityState -TEST_HORIZON = 150 -RANDOM_SEED = 985 +RANDOM_SEED = 284 LOGFILE = 'tests/action/test_ammo_use.log' -class TestAmmoUse(unittest.TestCase): +class TestAmmoUse(ScriptedTestTemplate): @classmethod def setUpClass(cls): - # only use Combat agents - cls.config = ScriptedAgentTestConfig() - cls.config.PLAYERS = [baselines.Melee, baselines.Range, baselines.Mage] - cls.config.PLAYER_N = 3 - cls.config.IMMORTAL = True + super().setUpClass() - # detailed logging for debugging + # config specific to the tests here + cls.config.IMMORTAL = True cls.config.LOG_VERBOSE = False if cls.config.LOG_VERBOSE: logging.basicConfig(filename=LOGFILE, level=logging.INFO) - # set up agents to test ammo use - cls.policy = { 1:'Melee', 2:'Range', 3:'Mage' } - # 1 cannot hit 3, 2 can hit 1, 3 cannot hit 2 - cls.spawn_locs = { 1:(17, 17), 2:(17, 19), 3:(21, 21) } - cls.ammo = { 1:Item.Scrap, 2:Item.Shaving, 3:Item.Shard } - cls.ammo_quantity = 2 - - # items to provide - cls.item_sig = {} - for ent_id in cls.policy: - cls.item_sig[ent_id] = [] - for item in [cls.ammo[ent_id], Item.Top, Item.Gloves, Item.Ration, Item.Poultice]: - for lvl in [0, 3]: - cls.item_sig[ent_id].append((item, lvl)) - - def _change_spawn_pos(self, realm, ent_id, pos): - # check if the position is valid - assert realm.map.tiles[pos].habitable, "Given pos is not habitable." - realm.players[ent_id].row.update(pos[0]) - realm.players[ent_id].col.update(pos[1]) - realm.players[ent_id].spawn_pos = pos - - def _provide_item(self, realm, ent_id, item, level, quantity): - realm.players[ent_id].inventory.receive( - item(realm, level=level, quantity=quantity)) - - def _setup_env(self): - """ set up a new env and perform initial checks """ - env = ScriptedAgentTestEnv(self.config, seed=RANDOM_SEED) - env.reset() - - for ent_id, pos in self.spawn_locs.items(): - self._change_spawn_pos(env.realm, ent_id, pos) - env.realm.players[ent_id].gold.update(5) - for item_sig in self.item_sig[ent_id]: - if item_sig[0] == self.ammo[ent_id]: - self._provide_item(env.realm, ent_id, item_sig[0], item_sig[1], self.ammo_quantity) - else: - self._provide_item(env.realm, ent_id, item_sig[0], item_sig[1], 1) - env.obs = env._compute_observations() - - # check if the agents are in specified positions - for ent_id, pos in self.spawn_locs.items(): - self.assertEqual(env.realm.players[ent_id].policy, self.policy[ent_id]) - self.assertEqual(env.realm.players[ent_id].pos, pos) - - # agents see each other - for other, pos in self.spawn_locs.items(): - self.assertTrue(other in env.obs[ent_id].entities.ids) - - # ammo instances are in the datastore and global item registry (realm) - inventory = env.obs[ent_id].inventory - self.assertTrue(inventory.len == len(self.item_sig[ent_id])) - for inv_idx in range(inventory.len): - item_id = inventory.id(inv_idx) - self.assertTrue(ItemState.Query.by_id(env.realm.datastore, item_id) is not None) - self.assertTrue(item_id in env.realm.items) - - # agents have ammo - for lvl in [0, 3]: - inv_idx = inventory.sig(self.ammo[ent_id].ITEM_TYPE_ID, lvl) - self.assertTrue(inv_idx is not None) - self.assertEqual(self.ammo_quantity, # provided 2 ammos - ItemState.parse_array(inventory.values[inv_idx]).quantity) - - # check ActionTargets - gym_obs = env.obs[ent_id].to_gym() - - # ATTACK Target mask - entities = env.obs[ent_id].entities.ids - mask = gym_obs['ActionTargets'][action.Attack][action.Target][:len(entities)] > 0 - if ent_id == 1: - self.assertTrue(2 in entities[mask]) - self.assertTrue(3 not in entities[mask]) - if ent_id == 2: - self.assertTrue(1 in entities[mask]) - self.assertTrue(3 not in entities[mask]) - if ent_id == 3: - self.assertTrue(1 not in entities[mask]) - self.assertTrue(2 not in entities[mask]) - - # USE InventoryItem mask - inventory = env.obs[ent_id].inventory - mask = gym_obs['ActionTargets'][action.Use][action.InventoryItem][:inventory.len] > 0 - for item_sig in self.item_sig[ent_id]: - inv_idx = inventory.sig(item_sig[0].ITEM_TYPE_ID, item_sig[1]) - if item_sig[1] == 0: - # items that can be used - self.assertTrue(inventory.id(inv_idx) in inventory.ids[mask]) - else: - # items that are too high to use - self.assertTrue(inventory.id(inv_idx) not in inventory.ids[mask]) - - # SELL InventoryItem mask - mask = gym_obs['ActionTargets'][action.Sell][action.InventoryItem][:inventory.len] > 0 - for item_sig in self.item_sig[ent_id]: - inv_idx = inventory.sig(item_sig[0].ITEM_TYPE_ID, item_sig[1]) - # the agent can sell anything now - self.assertTrue(inventory.id(inv_idx) in inventory.ids[mask]) - - # BUY MarketItem mask -- there is nothing on the market, so mask should be all 0 - market = env.obs[ent_id].market - mask = gym_obs['ActionTargets'][action.Buy][action.MarketItem][:market.len] > 0 - self.assertTrue(len(market.ids[mask]) == 0) - - return env - def test_ammo_fire_all(self): - env = self._setup_env() + env = self._setup_env(random_seed=RANDOM_SEED) # First tick actions: USE (equip) level-0 ammo env.step({ ent_id: { action.Use: - { action.InventoryItem: env.obs[ent_id].inventory.sig(self.ammo[ent_id].ITEM_TYPE_ID, 0) } + { action.InventoryItem: env.obs[ent_id].inventory.sig(self.ammo[ent_id], 0) } } for ent_id in self.ammo }) # check if the agents have equipped the ammo for ent_id in self.ammo: gym_obs = env.obs[ent_id].to_gym() inventory = env.obs[ent_id].inventory - inv_idx = inventory.sig(self.ammo[ent_id].ITEM_TYPE_ID, 0) + inv_idx = inventory.sig(self.ammo[ent_id], 0) self.assertEqual(1, # True ItemState.parse_array(inventory.values[inv_idx]).equipped) @@ -168,7 +55,7 @@ def test_ammo_fire_all(self): ammo_ids = [] for ent_id in self.ammo: inventory = env.obs[ent_id].inventory - inv_idx = inventory.sig(self.ammo[ent_id].ITEM_TYPE_ID, 0) + inv_idx = inventory.sig(self.ammo[ent_id], 0) item_info = ItemState.parse_array(inventory.values[inv_idx]) if ent_id == 2: # only agent 2's attack is valid and consume ammo @@ -202,7 +89,7 @@ def test_ammo_fire_all(self): # DONE def test_cannot_use_listed_items(self): - env = self._setup_env() + env = self._setup_env(random_seed=RANDOM_SEED) sell_price = 1 @@ -221,7 +108,7 @@ def test_cannot_use_listed_items(self): # First tick actions: SELL level-0 ammo env.step({ ent_id: { action.Sell: - { action.InventoryItem: env.obs[ent_id].inventory.sig(self.ammo[ent_id].ITEM_TYPE_ID, 0), + { action.InventoryItem: env.obs[ent_id].inventory.sig(self.ammo[ent_id], 0), action.Price: sell_price } } for ent_id in self.ammo }) @@ -229,7 +116,7 @@ def test_cannot_use_listed_items(self): for ent_id in self.ammo: gym_obs = env.obs[ent_id].to_gym() inventory = env.obs[ent_id].inventory - inv_idx = inventory.sig(self.ammo[ent_id].ITEM_TYPE_ID, 0) + inv_idx = inventory.sig(self.ammo[ent_id], 0) item_info = ItemState.parse_array(inventory.values[inv_idx]) # ItemState data self.assertEqual(sell_price, item_info.listed_price) @@ -256,25 +143,26 @@ def test_cannot_use_listed_items(self): # Second tick actions: USE ammo, which should NOT happen env.step({ ent_id: { action.Use: - { action.InventoryItem: env.obs[ent_id].inventory.sig(self.ammo[ent_id].ITEM_TYPE_ID, 0) } + { action.InventoryItem: env.obs[ent_id].inventory.sig(self.ammo[ent_id], 0) } } for ent_id in self.ammo }) # check if the agents have equipped the ammo for ent_id in self.ammo: inventory = env.obs[ent_id].inventory - inv_idx = inventory.sig(self.ammo[ent_id].ITEM_TYPE_ID, 0) + inv_idx = inventory.sig(self.ammo[ent_id], 0) self.assertEqual(0, # False ItemState.parse_array(inventory.values[inv_idx]).equipped) # DONE def test_receive_extra_ammo_swap(self): - env = self._setup_env() + env = self._setup_env(random_seed=RANDOM_SEED) extra_ammo = 500 - scrap_lvl0 = (Item.Scrap.ITEM_TYPE_ID, 0) - scrap_lvl1 = (Item.Scrap.ITEM_TYPE_ID, 1) - scrap_lvl3 = (Item.Scrap.ITEM_TYPE_ID, 3) + scrap_lvl0 = (Item.Scrap, 0) + scrap_lvl1 = (Item.Scrap, 1) + scrap_lvl3 = (Item.Scrap, 3) + sig_int_tuple = lambda sig: (sig[0].ITEM_TYPE_ID, sig[1]) for ent_id in self.policy: # provide extra scrap @@ -291,9 +179,9 @@ def test_receive_extra_ammo_swap(self): inv_realm = { item.signature: item.quantity.val for item in env.realm.players[ent_id].inventory.items if isinstance(item, Item.Stack) } - self.assertTrue( scrap_lvl0 in inv_realm ) - self.assertTrue( scrap_lvl1 in inv_realm ) - self.assertEqual( inv_realm[scrap_lvl1], extra_ammo ) + self.assertTrue( sig_int_tuple(scrap_lvl0) in inv_realm ) + self.assertTrue( sig_int_tuple(scrap_lvl1) in inv_realm ) + self.assertEqual( inv_realm[sig_int_tuple(scrap_lvl1)], extra_ammo ) # item datastore inv_obs = env.obs[ent_id].inventory @@ -303,7 +191,7 @@ def test_receive_extra_ammo_swap(self): ItemState.parse_array(inv_obs.values[inv_obs.sig(*scrap_lvl1)]).quantity) if ent_id == 1: # if the ammo has the same signature, the quantity is added to the existing stack - self.assertEqual( inv_realm[scrap_lvl0], extra_ammo + self.ammo_quantity ) + self.assertEqual( inv_realm[sig_int_tuple(scrap_lvl0)], extra_ammo + self.ammo_quantity ) self.assertEqual( extra_ammo + self.ammo_quantity, ItemState.parse_array(inv_obs.values[inv_obs.sig(*scrap_lvl0)]).quantity) # so there should be 1 more space @@ -311,7 +199,7 @@ def test_receive_extra_ammo_swap(self): else: # if the signature is different, it occupies a new inventory space - self.assertEqual( inv_realm[scrap_lvl0], extra_ammo ) + self.assertEqual( inv_realm[sig_int_tuple(scrap_lvl0)], extra_ammo ) self.assertEqual( extra_ammo, ItemState.parse_array(inv_obs.values[inv_obs.sig(*scrap_lvl0)]).quantity) # thus the inventory is full @@ -362,6 +250,79 @@ def test_receive_extra_ammo_swap(self): # DONE + def test_use_ration_poultice(self): + # cannot use level-3 ration & poultice due to low level + # can use level-0 ration & poultice to increase food/water/health + env = self._setup_env(random_seed=RANDOM_SEED) + + # make food/water/health 20 + res_dec_tick = env.config.RESOURCE_DEPLETION_RATE + init_res = 20 + for ent_id in self.policy: + env.realm.players[ent_id].resources.food.update(init_res) + env.realm.players[ent_id].resources.water.update(init_res) + env.realm.players[ent_id].resources.health.update(init_res) + env.obs = env._compute_observations() + + """First tick: try to use level-3 ration & poultice""" + ration_lvl3 = (Item.Ration, 3) + poultice_lvl3 = (Item.Poultice, 3) + + actions = {} + ent_id = 1; actions[ent_id] = { action.Use: + { action.InventoryItem: env.obs[ent_id].inventory.sig(*ration_lvl3) } } + ent_id = 2; actions[ent_id] = { action.Use: + { action.InventoryItem: env.obs[ent_id].inventory.sig(*ration_lvl3) } } + ent_id = 3; actions[ent_id] = { action.Use: + { action.InventoryItem: env.obs[ent_id].inventory.sig(*poultice_lvl3) } } + + env.step(actions) + + # check if the agents have used the ration & poultice + for ent_id in [1, 2]: + # cannot use due to low level, so still in the inventory + self.assertFalse( env.obs[ent_id].inventory.sig(*ration_lvl3) is None) + + # failed to restore food/water, so no change + resources = env.realm.players[ent_id].resources + self.assertEqual( resources.food.val, init_res - res_dec_tick) + self.assertEqual( resources.water.val, init_res - res_dec_tick) + + ent_id = 3 # failed to use the item + self.assertFalse( env.obs[ent_id].inventory.sig(*poultice_lvl3) is None) + self.assertEqual( env.realm.players[ent_id].resources.health.val, init_res) + + """Second tick: try to use level-0 ration & poultice""" + ration_lvl0 = (Item.Ration, 0) + poultice_lvl0 = (Item.Poultice, 0) + + actions = {} + ent_id = 1; actions[ent_id] = { action.Use: + { action.InventoryItem: env.obs[ent_id].inventory.sig(*ration_lvl0) } } + ent_id = 2; actions[ent_id] = { action.Use: + { action.InventoryItem: env.obs[ent_id].inventory.sig(*ration_lvl0) } } + ent_id = 3; actions[ent_id] = { action.Use: + { action.InventoryItem: env.obs[ent_id].inventory.sig(*poultice_lvl0) } } + + env.step(actions) + + # check if the agents have successfully used the ration & poultice + restore = env.config.PROFESSION_CONSUMABLE_RESTORE(0) + for ent_id in [1, 2]: + # items should be gone + self.assertTrue( env.obs[ent_id].inventory.sig(*ration_lvl0) is None) + + # successfully restored food/water + resources = env.realm.players[ent_id].resources + self.assertEqual( resources.food.val, init_res + restore - 2*res_dec_tick) + self.assertEqual( resources.water.val, init_res + restore - 2*res_dec_tick) + + ent_id = 3 # successfully restored health + self.assertTrue( env.obs[ent_id].inventory.sig(*poultice_lvl0) is None) # item gone + self.assertEqual( env.realm.players[ent_id].resources.health.val, init_res + restore) + + # DONE + if __name__ == '__main__': unittest.main() diff --git a/tests/action/test_destroy_give_gold.py b/tests/action/test_destroy_give_gold.py new file mode 100644 index 00000000..277c59cd --- /dev/null +++ b/tests/action/test_destroy_give_gold.py @@ -0,0 +1,296 @@ +import unittest +import logging + +# pylint: disable=import-error +from testhelpers import ScriptedTestTemplate + +from nmmo.io import action +from nmmo.systems import item as Item +from nmmo.systems.item import ItemState +from scripted import baselines + +RANDOM_SEED = 985 + +LOGFILE = 'tests/action/test_destroy_give_gold.log' + +class TestDestroyGiveGold(ScriptedTestTemplate): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # config specific to the tests here + cls.config.PLAYERS = [baselines.Melee, baselines.Range] + cls.config.PLAYER_N = 6 + + cls.policy = { 1:'Melee', 2:'Range', 3:'Melee', 4:'Range', 5:'Melee', 6:'Range' } + cls.spawn_locs = { 1:(17,17), 2:(21,21), 3:(17,17), 4:(21,21), 5:(21,21), 6:(17,17) } + cls.ammo = { 1:Item.Scrap, 2:Item.Shaving, 3:Item.Scrap, + 4:Item.Shaving, 5:Item.Scrap, 6:Item.Shaving } + + cls.config.LOG_VERBOSE = False + if cls.config.LOG_VERBOSE: + logging.basicConfig(filename=LOGFILE, level=logging.INFO) + + def test_destroy(self): + env = self._setup_env(random_seed=RANDOM_SEED) + + # check if level-0 and level-3 ammo are in the correct place + for ent_id in self.policy: + for idx, lvl in enumerate(self.item_level): + assert self.item_sig[ent_id][idx] == (self.ammo[ent_id], lvl) + + # equipped items cannot be destroyed, i.e. that action will be ignored + # this should be marked in the mask too + + """ First tick """ # First tick actions: USE (equip) level-0 ammo + env.step({ ent_id: { action.Use: { action.InventoryItem: + env.obs[ent_id].inventory.sig(*self.item_sig[ent_id][0]) } # level-0 ammo + } for ent_id in self.policy }) + + # check if the agents have equipped the ammo + for ent_id in self.policy: + ent_obs = env.obs[ent_id] + inv_idx = ent_obs.inventory.sig(*self.item_sig[ent_id][0]) # level-0 ammo + self.assertEqual(1, # True + ItemState.parse_array(ent_obs.inventory.values[inv_idx]).equipped) + + # check Destroy InventoryItem mask -- one cannot destroy equipped item + for item_sig in self.item_sig[ent_id]: + if item_sig == (self.ammo[ent_id], 0): # level-0 ammo + self.assertFalse(self._check_inv_mask(ent_obs, action.Destroy, item_sig)) + else: + # other items can be destroyed + self.assertTrue(self._check_inv_mask(ent_obs, action.Destroy, item_sig)) + + """ Second tick """ # Second tick actions: DESTROY ammo + actions = {} + + for ent_id in self.policy: + if ent_id in [1, 2]: + # agent 1 & 2, destroy the level-3 ammos, which are valid + actions[ent_id] = { action.Destroy: + { action.InventoryItem: env.obs[ent_id].inventory.sig(*self.item_sig[ent_id][1]) } } + else: + # other agents: destroy the equipped level-0 ammos, which are invalid + actions[ent_id] = { action.Destroy: + { action.InventoryItem: env.obs[ent_id].inventory.sig(*self.item_sig[ent_id][0]) } } + env.step(actions) + + # check if the ammos were destroyed + for ent_id in self.policy: + if ent_id in [1, 2]: + inv_idx = env.obs[ent_id].inventory.sig(*self.item_sig[ent_id][1]) + self.assertTrue(inv_idx is None) # valid actions, thus destroyed + else: + inv_idx = env.obs[ent_id].inventory.sig(*self.item_sig[ent_id][0]) + self.assertTrue(inv_idx is not None) # invalid actions, thus not destroyed + + # DONE + + def test_give_team_tile_npc(self): + # cannot give to self (should be masked) + # cannot give if not on the same tile (should be masked) + # cannot give to the other team member (should be masked) + # cannot give to npc (should be masked) + env = self._setup_env(random_seed=RANDOM_SEED) + + # teleport the npc -1 to agent 5's location + self._change_spawn_pos(env.realm, -1, self.spawn_locs[5]) + env.obs = env._compute_observations() + + """ First tick actions """ + actions = {} + test_cond = {} + + # agent 1: give ammo to agent 3 (valid: the same team, same tile) + test_cond[1] = { 'tgt_id': 3, 'item_sig': self.item_sig[1][0], + 'ent_mask': True, 'inv_mask': True, 'valid': True } + # agent 2: give ammo to agent 2 (invalid: cannot give to self) + test_cond[2] = { 'tgt_id': 2, 'item_sig': self.item_sig[2][0], + 'ent_mask': False, 'inv_mask': True, 'valid': False } + # agent 3: give ammo to agent 6 (invalid: the same tile but other team) + test_cond[3] = { 'tgt_id': 6, 'item_sig': self.item_sig[3][0], + 'ent_mask': False, 'inv_mask': True, 'valid': False } + # agent 4: give ammo to agent 5 (invalid: the same team but other tile) + test_cond[4] = { 'tgt_id': 5, 'item_sig': self.item_sig[4][0], + 'ent_mask': False, 'inv_mask': True, 'valid': False } + # agent 5: give ammo to npc -1 (invalid, should be masked) + test_cond[5] = { 'tgt_id': -1, 'item_sig': self.item_sig[5][0], + 'ent_mask': False, 'inv_mask': True, 'valid': False } + + actions = self._check_assert_make_action(env, action.Give, test_cond) + env.step(actions) + + # check the results + for ent_id, cond in test_cond.items(): + self.assertEqual( cond['valid'], + env.obs[ent_id].inventory.sig(*cond['item_sig']) is None) + + if ent_id == 1: # agent 1 gave ammo stack to agent 3 + tgt_inv = env.obs[cond['tgt_id']].inventory + inv_idx = tgt_inv.sig(*cond['item_sig']) + self.assertEqual(2 * self.ammo_quantity, + ItemState.parse_array(tgt_inv.values[inv_idx]).quantity) + + # DONE + + def test_give_equipped_listed(self): + # cannot give equipped items (should be masked) + # cannot give listed items (should be masked) + env = self._setup_env(random_seed=RANDOM_SEED) + + """ First tick actions """ + actions = {} + + # agent 1: equip the ammo + ent_id = 1; item_sig = self.item_sig[ent_id][0] + self.assertTrue( + self._check_inv_mask(env.obs[ent_id], action.Use, item_sig)) + actions[ent_id] = { action.Use: { action.InventoryItem: + env.obs[ent_id].inventory.sig(*item_sig) } } + + # agent 2: list the ammo for sale + ent_id = 2; price = 5; item_sig = self.item_sig[ent_id][0] + self.assertTrue( + self._check_inv_mask(env.obs[ent_id], action.Sell, item_sig)) + actions[ent_id] = { action.Sell: { + action.InventoryItem: env.obs[ent_id].inventory.sig(*item_sig), + action.Price: price } } + + env.step(actions) + + # Check the first tick actions + # agent 1: equip the ammo + ent_id = 1; item_sig = self.item_sig[ent_id][0] + inv_idx = env.obs[ent_id].inventory.sig(*item_sig) + self.assertEqual(1, + ItemState.parse_array(env.obs[ent_id].inventory.values[inv_idx]).equipped) + + # agent 2: list the ammo for sale + ent_id = 2; price = 5; item_sig = self.item_sig[ent_id][0] + inv_idx = env.obs[ent_id].inventory.sig(*item_sig) + self.assertEqual(price, + ItemState.parse_array(env.obs[ent_id].inventory.values[inv_idx]).listed_price) + self.assertTrue(env.obs[ent_id].inventory.id(inv_idx) in env.obs[ent_id].market.ids) + + """ Second tick actions """ + actions = {} + test_cond = {} + + # agent 1: give equipped ammo to agent 3 (invalid: should be masked) + test_cond[1] = { 'tgt_id': 3, 'item_sig': self.item_sig[1][0], + 'ent_mask': True, 'inv_mask': False, 'valid': False } + # agent 2: give listed ammo to agent 4 (invalid: should be masked) + test_cond[2] = { 'tgt_id': 4, 'item_sig': self.item_sig[2][0], + 'ent_mask': True, 'inv_mask': False, 'valid': False } + + actions = self._check_assert_make_action(env, action.Give, test_cond) + env.step(actions) + + # Check the second tick actions + # check the results + for ent_id, cond in test_cond.items(): + self.assertEqual( cond['valid'], + env.obs[ent_id].inventory.sig(*cond['item_sig']) is None) + + # DONE + + def test_give_full_inventory(self): + # cannot give to an agent with the full inventory, + # but it's possible if the agent has the same ammo stack + env = self._setup_env(random_seed=RANDOM_SEED) + + # make the inventory full for agents 1, 2 + extra_items = { (Item.Bottom, 0), (Item.Bottom, 3) } + for ent_id in [1, 2]: + for item_sig in extra_items: + self.item_sig[ent_id].append(item_sig) + self._provide_item(env.realm, ent_id, item_sig[0], item_sig[1], 1) + + env.obs = env._compute_observations() + + # check if the inventory is full + for ent_id in [1, 2]: + self.assertEqual(env.obs[ent_id].inventory.len, env.config.ITEM_INVENTORY_CAPACITY) + self.assertTrue(env.realm.players[ent_id].inventory.space == 0) + + """ First tick actions """ + actions = {} + test_cond = {} + + # agent 3: give ammo to agent 1 (the same ammo stack, so valid) + test_cond[3] = { 'tgt_id': 1, 'item_sig': self.item_sig[3][0], + 'ent_mask': True, 'inv_mask': True, 'valid': True } + # agent 4: give gloves to agent 2 (not the stack, so invalid) + test_cond[4] = { 'tgt_id': 2, 'item_sig': self.item_sig[4][4], + 'ent_mask': True, 'inv_mask': True, 'valid': False } + + actions = self._check_assert_make_action(env, action.Give, test_cond) + env.step(actions) + + # Check the first tick actions + # check the results + for ent_id, cond in test_cond.items(): + self.assertEqual( cond['valid'], + env.obs[ent_id].inventory.sig(*cond['item_sig']) is None) + + if ent_id == 3: # successfully gave the ammo stack to agent 1 + tgt_inv = env.obs[cond['tgt_id']].inventory + inv_idx = tgt_inv.sig(*cond['item_sig']) + self.assertEqual(2 * self.ammo_quantity, + ItemState.parse_array(tgt_inv.values[inv_idx]).quantity) + + # DONE + + def test_give_gold(self): + # cannot give to an npc (should be masked) + # cannot give to the other team member (should be masked) + # cannot give to self (should be masked) + # cannot give if not on the same tile (should be masked) + env = self._setup_env(random_seed=RANDOM_SEED) + + # teleport the npc -1 to agent 3's location + self._change_spawn_pos(env.realm, -1, self.spawn_locs[3]) + env.obs = env._compute_observations() + + test_cond = {} + + # NOTE: the below tests rely on the static execution order from 1 to N + # agent 1: give gold to agent 3 (valid: the same team, same tile) + test_cond[1] = { 'tgt_id': 3, 'gold': 1, 'ent_mask': True, + 'ent_gold': self.init_gold-1, 'tgt_gold': self.init_gold+1 } + # agent 2: give gold to agent 4 (valid: the same team, same tile) + test_cond[2] = { 'tgt_id': 4, 'gold': 100, 'ent_mask': True, + 'ent_gold': 0, 'tgt_gold': 2*self.init_gold } + # agent 3: give gold to npc -1 (invalid: cannot give to npc) + # ent_gold is self.init_gold+1 because (3) got 1 gold from (1) + test_cond[3] = { 'tgt_id': -1, 'gold': 1, 'ent_mask': False, + 'ent_gold': self.init_gold+1, 'tgt_gold': self.init_gold } + # agent 4: give -1 gold to 2 (invalid: cannot give minus gold) + # ent_gold is 2*self.init_gold because (4) got 5 gold from (2) + # tgt_gold is 0 because (2) gave all gold to (4) + test_cond[4] = { 'tgt_id': 2, 'gold': -1, 'ent_mask': True, + 'ent_gold': 2*self.init_gold, 'tgt_gold': 0 } + # agent 5: give gold to agent 2 (invalid: the same tile but other team) + # tgt_gold is 0 because (2) gave all gold to (4) + test_cond[5] = { 'tgt_id': 2, 'gold': 1, 'ent_mask': False, + 'ent_gold': self.init_gold, 'tgt_gold': 0 } + # agent 6: give gold to agent 4 (invalid: the same team but other tile) + # tgt_gold is 2*self.init_gold because (4) got 5 gold from (2) + test_cond[6] = { 'tgt_id': 4, 'gold': 1, 'ent_mask': False, + 'ent_gold': self.init_gold, 'tgt_gold': 2*self.init_gold } + + actions = self._check_assert_make_action(env, action.GiveGold, test_cond) + env.step(actions) + + # check the results + for ent_id, cond in test_cond.items(): + self.assertEqual(cond['ent_gold'], env.realm.players[ent_id].gold.val) + if cond['tgt_id'] > 0: + self.assertEqual(cond['tgt_gold'], env.realm.players[cond['tgt_id']].gold.val) + + # DONE + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/action/test_sell_buy.py b/tests/action/test_sell_buy.py new file mode 100644 index 00000000..35d8acc9 --- /dev/null +++ b/tests/action/test_sell_buy.py @@ -0,0 +1,180 @@ +import unittest +import logging + +# pylint: disable=import-error +from testhelpers import ScriptedTestTemplate + +from nmmo.io import action +from nmmo.systems import item as Item +from nmmo.systems.item import ItemState +from scripted import baselines + +RANDOM_SEED = 985 + +LOGFILE = 'tests/action/test_sell_buy.log' + +class TestSellBuy(ScriptedTestTemplate): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # config specific to the tests here + cls.config.PLAYERS = [baselines.Melee, baselines.Range] + cls.config.PLAYER_N = 6 + + cls.policy = { 1:'Melee', 2:'Range', 3:'Melee', 4:'Range', 5:'Melee', 6:'Range' } + cls.ammo = { 1:Item.Scrap, 2:Item.Shaving, 3:Item.Scrap, + 4:Item.Shaving, 5:Item.Scrap, 6:Item.Shaving } + + cls.config.LOG_VERBOSE = False + if cls.config.LOG_VERBOSE: + logging.basicConfig(filename=LOGFILE, level=logging.INFO) + + + def test_sell_buy(self): + # cannot list an item with 0 price + # cannot list an equipped item for sale (should be masked) + # cannot buy an item with the full inventory, + # but it's possible if the agent has the same ammo stack + # cannot buy its own item (should be masked) + # cannot buy an item if gold is not enough (should be masked) + # cannot list an already listed item for sale (should be masked) + env = self._setup_env(random_seed=RANDOM_SEED) + + # make the inventory full for agents 1, 2 + extra_items = { (Item.Bottom, 0), (Item.Bottom, 3) } + for ent_id in [1, 2]: + for item_sig in extra_items: + self.item_sig[ent_id].append(item_sig) + self._provide_item(env.realm, ent_id, item_sig[0], item_sig[1], 1) + + env.obs = env._compute_observations() + + # check if the inventory is full + for ent_id in [1, 2]: + self.assertEqual(env.obs[ent_id].inventory.len, env.config.ITEM_INVENTORY_CAPACITY) + self.assertTrue(env.realm.players[ent_id].inventory.space == 0) + + """ First tick actions """ + # cannot list an item with 0 price + actions = {} + + # agent 1-2: equip the ammo + for ent_id in [1, 2]: + item_sig = self.item_sig[ent_id][0] + self.assertTrue( + self._check_inv_mask(env.obs[ent_id], action.Use, item_sig)) + actions[ent_id] = { action.Use: { action.InventoryItem: + env.obs[ent_id].inventory.sig(*item_sig) } } + + # agent 4: list the ammo for sale with price 0 (invalid) + ent_id = 4; price = 0; item_sig = self.item_sig[ent_id][0] + actions[ent_id] = { action.Sell: { + action.InventoryItem: env.obs[ent_id].inventory.sig(*item_sig), + action.Price: price } } + + env.step(actions) + + # Check the first tick actions + # agent 1-2: the ammo equipped, thus should be masked for sale + for ent_id in [1, 2]: + item_sig = self.item_sig[ent_id][0] + inv_idx = env.obs[ent_id].inventory.sig(*item_sig) + self.assertEqual(1, # equipped = true + ItemState.parse_array(env.obs[ent_id].inventory.values[inv_idx]).equipped) + self.assertFalse( # not allowed to list + self._check_inv_mask(env.obs[ent_id], action.Sell, item_sig)) + + # and nothing is listed because agent 4's SELL is invalid + self.assertTrue(len(env.obs[ent_id].market.ids) == 0) + + """ Second tick actions """ + # listing the level-0 ammo with different prices + # cannot list an equipped item for sale (should be masked) + + listing_price = { 1:1, 2:5, 3:15, 4:3, 5:1 } # gold + for ent_id in listing_price: + item_sig = self.item_sig[ent_id][0] + actions[ent_id] = { action.Sell: { + action.InventoryItem: env.obs[ent_id].inventory.sig(*item_sig), + action.Price: listing_price[ent_id] } } + + env.step(actions) + + # Check the second tick actions + # agent 1-2: the ammo equipped, thus not listed for sale + # agent 3-5's ammos listed for sale + for ent_id in listing_price: + item_id = env.obs[ent_id].inventory.id(0) + + if ent_id in [1, 2]: # failed to list for sale + self.assertFalse(item_id in env.obs[ent_id].market.ids) # not listed + self.assertEqual(0, + ItemState.parse_array(env.obs[ent_id].inventory.values[0]).listed_price) + + else: # should succeed to list for sale + self.assertTrue(item_id in env.obs[ent_id].market.ids) # listed + self.assertEqual(listing_price[ent_id], # sale price set + ItemState.parse_array(env.obs[ent_id].inventory.values[0]).listed_price) + + # should not buy mine + self.assertFalse( self._check_mkt_mask(env.obs[ent_id], item_id)) + + # should not list the same item twice + self.assertFalse( + self._check_inv_mask(env.obs[ent_id], action.Sell, self.item_sig[ent_id][0])) + + """ Third tick actions """ + # cannot buy an item with the full inventory, + # but it's possible if the agent has the same ammo stack + # cannot buy its own item (should be masked) + # cannot buy an item if gold is not enough (should be masked) + # cannot list an already listed item for sale (should be masked) + + test_cond = {} + + # agent 1: buy agent 5's ammo (valid: 1 has the same ammo stack) + # although 1's inventory is full, this action is valid + agent5_ammo = env.obs[5].inventory.id(0) + test_cond[1] = { 'item_id': agent5_ammo, 'mkt_mask': True } + + # agent 2: buy agent 5's ammo (invalid: full space and no same stack) + test_cond[2] = { 'item_id': agent5_ammo, 'mkt_mask': False } + + # agent 4: cannot buy its own item (invalid) + test_cond[4] = { 'item_id': env.obs[4].inventory.id(0), 'mkt_mask': False } + + # agent 5: cannot buy agent 3's ammo (invalid: not enought gold) + test_cond[5] = { 'item_id': env.obs[3].inventory.id(0), 'mkt_mask': False } + + actions = self._check_assert_make_action(env, action.Buy, test_cond) + + # agent 3: list an already listed item for sale (try different price) + ent_id = 3; item_sig = self.item_sig[ent_id][0] + actions[ent_id] = { action.Sell: { + action.InventoryItem: env.obs[ent_id].inventory.sig(*item_sig), + action.Price: 7 } } # try to set different price + + env.step(actions) + + # Check the third tick actions + # agent 1: buy agent 5's ammo (valid: 1 has the same ammo stack) + # agent 5's ammo should be gone + ent_id = 5; self.assertFalse( agent5_ammo in env.obs[ent_id].inventory.ids) + self.assertEqual( env.realm.players[ent_id].gold.val, # gold transfer + self.init_gold + listing_price[ent_id]) + + ent_id = 1; self.assertEqual(2 * self.ammo_quantity, # ammo transfer + ItemState.parse_array(env.obs[ent_id].inventory.values[0]).quantity) + self.assertEqual( env.realm.players[ent_id].gold.val, # gold transfer + self.init_gold - listing_price[ent_id]) + + # agent 2-4: invalid buy, no exchange, thus the same money + for ent_id in [2, 3, 4]: + self.assertEqual( env.realm.players[ent_id].gold.val, self.init_gold) + + # DONE + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/testhelpers.py b/tests/testhelpers.py index 7974e267..7b666772 100644 --- a/tests/testhelpers.py +++ b/tests/testhelpers.py @@ -1,13 +1,15 @@ import logging +import unittest + from copy import deepcopy import numpy as np import nmmo from scripted import baselines -from nmmo.io.action import Move, Attack, Sell, Use, Give, Buy from nmmo.entity.entity import EntityState -from nmmo.systems.item import ItemState +from nmmo.io import action +from nmmo.systems import item as Item # this function can be replaced by assertDictEqual # but might be still useful for debugging @@ -81,7 +83,7 @@ def player_total(env): def count_actions(tick, actions): cnt_action = {} - for atn in (Move, Attack, Sell, Use, Give, Buy): + for atn in (action.Move, action.Attack, action.Sell, action.Use, action.Give, action.Buy): cnt_action[atn] = 0 for ent_id in actions: @@ -92,9 +94,9 @@ def count_actions(tick, actions): cnt_action[atn] = 1 info_str = f"Tick: {tick}, acting agents: {len(actions)}, action counts " + \ - f"move: {cnt_action[Move]}, attack: {cnt_action[Attack]}, " + \ - f"sell: {cnt_action[Sell]}, use: {cnt_action[Move]}, " + \ - f"give: {cnt_action[Give]}, buy: {cnt_action[Buy]}" + f"move: {cnt_action[action.Move]}, attack: {cnt_action[action.Attack]}, " + \ + f"sell: {cnt_action[action.Sell]}, use: {cnt_action[action.Move]}, " + \ + f"give: {cnt_action[action.Give]}, buy: {cnt_action[action.Buy]}" logging.info(info_str) return cnt_action @@ -139,7 +141,7 @@ def reset(self, map_id=None, seed=None, options=None): self.actions = {} # manually resetting the EntityState, ItemState datastore tables EntityState.State.table(self.realm.datastore).reset() - ItemState.State.table(self.realm.datastore).reset() + Item.ItemState.State.table(self.realm.datastore).reset() return super().reset(map_id=map_id, seed=seed, options=options) def _compute_scripted_agent_actions(self, actions): @@ -170,3 +172,189 @@ def _process_actions(self, actions, obs): # bypass the current _process_actions() return actions + + +# pylint: disable=invalid-name,protected-access +class ScriptedTestTemplate(unittest.TestCase): + + @classmethod + def setUpClass(cls): + # only use Combat agents + cls.config = ScriptedAgentTestConfig() + cls.config.PLAYERS = [baselines.Melee, baselines.Range, baselines.Mage] + cls.config.PLAYER_N = 3 + #cls.config.IMMORTAL = True + + # set up agents to test ammo use + cls.policy = { 1:'Melee', 2:'Range', 3:'Mage' } + # 1 cannot hit 3, 2 can hit 1, 3 cannot hit 2 + cls.spawn_locs = { 1:(17, 17), 2:(17, 19), 3:(21, 21) } + cls.ammo = { 1:Item.Scrap, 2:Item.Shaving, 3:Item.Shard } + cls.ammo_quantity = 2 + + # items to provide + cls.init_gold = 5 + cls.item_level = [0, 3] # 0 can be used, 3 cannot be used + cls.item_sig = {} + + def _change_spawn_pos(self, realm, ent_id, new_pos): + # check if the position is valid + assert realm.map.tiles[new_pos].habitable, "Given pos is not habitable." + assert realm.entity(ent_id), "No such entity in the realm" + + entity = realm.entity(ent_id) + old_pos = entity.pos + realm.map.tiles[old_pos].remove_entity(ent_id) + + # set to new pos + entity.row.update(new_pos[0]) + entity.col.update(new_pos[1]) + entity.spawn_pos = new_pos + realm.map.tiles[new_pos].add_entity(entity) + + def _provide_item(self, realm, ent_id, item, level, quantity): + realm.players[ent_id].inventory.receive( + item(realm, level=level, quantity=quantity)) + + def _make_item_sig(self): + item_sig = {} + for ent_id, ammo in self.ammo.items(): + item_sig[ent_id] = [] + for item in [ammo, Item.Top, Item.Gloves, Item.Ration, Item.Poultice]: + for lvl in self.item_level: + item_sig[ent_id].append((item, lvl)) + + return item_sig + + def _setup_env(self, random_seed, check_assert=True): + """ set up a new env and perform initial checks """ + env = ScriptedAgentTestEnv(self.config, seed=random_seed) + env.reset() + + # provide money for all + for ent_id in env.realm.players: + env.realm.players[ent_id].gold.update(self.init_gold) + + # provide items that are in item_sig + self.item_sig = self._make_item_sig() + for ent_id, items in self.item_sig.items(): + for item_sig in items: + if item_sig[0] == self.ammo[ent_id]: + self._provide_item(env.realm, ent_id, item_sig[0], item_sig[1], self.ammo_quantity) + else: + self._provide_item(env.realm, ent_id, item_sig[0], item_sig[1], 1) + + # teleport the players, if provided with specific locations + for ent_id, pos in self.spawn_locs.items(): + self._change_spawn_pos(env.realm, ent_id, pos) + + env.obs = env._compute_observations() + + if check_assert: + self._check_default_asserts(env) + + return env + + def _check_ent_mask(self, ent_obs, atn, target_id): + assert atn in [action.Give, action.GiveGold], "Invalid action" + gym_obs = ent_obs.to_gym() + mask = gym_obs['ActionTargets'][atn][action.Target][:ent_obs.entities.len] > 0 + + return target_id in ent_obs.entities.ids[mask] + + def _check_inv_mask(self, ent_obs, atn, item_sig): + assert atn in [action.Destroy, action.Give, action.Sell, action.Use], "Invalid action" + gym_obs = ent_obs.to_gym() + mask = gym_obs['ActionTargets'][atn][action.InventoryItem][:ent_obs.inventory.len] > 0 + inv_idx = ent_obs.inventory.sig(*item_sig) + + return ent_obs.inventory.id(inv_idx) in ent_obs.inventory.ids[mask] + + def _check_mkt_mask(self, ent_obs, item_id): + gym_obs = ent_obs.to_gym() + mask = gym_obs['ActionTargets'][action.Buy][action.MarketItem][:ent_obs.market.len] > 0 + + return item_id in ent_obs.market.ids[mask] + + def _check_default_asserts(self, env): + """ The below asserts are based on the hardcoded values in setUpClass() + This should not run when different values were used + """ + # check if the agents are in specified positions + for ent_id, pos in self.spawn_locs.items(): + self.assertEqual(env.realm.players[ent_id].pos, pos) + + for ent_id, sig_list in self.item_sig.items(): + # ammo instances are in the datastore and global item registry (realm) + inventory = env.obs[ent_id].inventory + self.assertTrue(inventory.len == len(sig_list)) + for inv_idx in range(inventory.len): + item_id = inventory.id(inv_idx) + self.assertTrue(Item.ItemState.Query.by_id(env.realm.datastore, item_id) is not None) + self.assertTrue(item_id in env.realm.items) + + for lvl in self.item_level: + inv_idx = inventory.sig(self.ammo[ent_id], lvl) + self.assertTrue(inv_idx is not None) + self.assertEqual(self.ammo_quantity, # provided 2 ammos + Item.ItemState.parse_array(inventory.values[inv_idx]).quantity) + + # check ActionTargets + ent_obs = env.obs[ent_id] + + if env.config.ITEM_SYSTEM_ENABLED: + # USE InventoryItem mask + for item_sig in sig_list: + if item_sig[1] == 0: + # items that can be used + self.assertTrue(self._check_inv_mask(ent_obs, action.Use, item_sig)) + else: + # items that are too high to use + self.assertFalse(self._check_inv_mask(ent_obs, action.Use, item_sig)) + + if env.config.EXCHANGE_SYSTEM_ENABLED: + # SELL InventoryItem mask + for item_sig in sig_list: + # the agent can sell anything now + self.assertTrue(self._check_inv_mask(ent_obs, action.Sell, item_sig)) + + # BUY MarketItem mask -- there is nothing on the market, so mask should be all 0 + self.assertTrue(len(env.obs[ent_id].market.ids) == 0) + + def _check_assert_make_action(self, env, atn, test_cond): + assert atn in [action.Give, action.GiveGold, action.Buy], "Invalid action" + actions = {} + for ent_id, cond in test_cond.items(): + ent_obs = env.obs[ent_id] + + if atn in [action.Give, action.GiveGold]: + # self should be always masked + self.assertFalse(self._check_ent_mask(ent_obs, atn, ent_id)) + + # check if the target is masked as expected + self.assertEqual( cond['ent_mask'], + self._check_ent_mask(ent_obs, atn, cond['tgt_id']) ) + + if atn in [action.Give]: + self.assertEqual( cond['inv_mask'], + self._check_inv_mask(ent_obs, atn, cond['item_sig']) ) + + if atn in [action.Buy]: + self.assertEqual( cond['mkt_mask'], + self._check_mkt_mask(ent_obs, cond['item_id']) ) + + # append the actions + if atn == action.Give: + actions[ent_id] = { action.Give: { + action.InventoryItem: env.obs[ent_id].inventory.sig(*cond['item_sig']), + action.Target: cond['tgt_id'] } } + + elif atn == action.GiveGold: + actions[ent_id] = { action.GiveGold: + { action.Target: cond['tgt_id'], action.Price: cond['gold'] } } + + elif atn == action.Buy: + mkt_idx = ent_obs.market.index(cond['item_id']) + actions[ent_id] = { action.Buy: { action.MarketItem: mkt_idx } } + + return actions