chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
from ray.rllib.env.multi_agent_env import make_multi_agent
|
||||
from ray.rllib.examples.envs.classes.cartpole_with_dict_observation_space import (
|
||||
CartPoleWithDictObservationSpace,
|
||||
)
|
||||
from ray.rllib.examples.envs.classes.multi_agent.guess_the_number_game import (
|
||||
GuessTheNumberGame,
|
||||
)
|
||||
from ray.rllib.examples.envs.classes.multi_agent.two_step_game import (
|
||||
TwoStepGame,
|
||||
TwoStepGameWithGroupedAgents,
|
||||
)
|
||||
from ray.rllib.examples.envs.classes.nested_space_repeat_after_me_env import (
|
||||
NestedSpaceRepeatAfterMeEnv,
|
||||
)
|
||||
from ray.rllib.examples.envs.classes.stateless_cartpole import StatelessCartPole
|
||||
|
||||
# Backward compatibility.
|
||||
__all__ = [
|
||||
"GuessTheNumberGame",
|
||||
"TwoStepGame",
|
||||
"TwoStepGameWithGroupedAgents",
|
||||
]
|
||||
|
||||
|
||||
MultiAgentCartPole = make_multi_agent("CartPole-v1")
|
||||
MultiAgentMountainCar = make_multi_agent("MountainCarContinuous-v0")
|
||||
MultiAgentPendulum = make_multi_agent("Pendulum-v1")
|
||||
MultiAgentStatelessCartPole = make_multi_agent(lambda config: StatelessCartPole(config))
|
||||
MultiAgentCartPoleWithDictObservationSpace = make_multi_agent(
|
||||
lambda config: CartPoleWithDictObservationSpace(config)
|
||||
)
|
||||
MultiAgentNestedSpaceRepeatAfterMeEnv = make_multi_agent(
|
||||
lambda config: NestedSpaceRepeatAfterMeEnv(config)
|
||||
)
|
||||
@@ -0,0 +1,207 @@
|
||||
import copy
|
||||
import random
|
||||
|
||||
import gymnasium as gym
|
||||
import numpy as np
|
||||
from gymnasium.spaces import Box, Discrete
|
||||
|
||||
|
||||
class SimpleContextualBandit(gym.Env):
|
||||
"""Simple env w/ 2 states and 3 actions (arms): 0, 1, and 2.
|
||||
|
||||
Episodes last only for one timestep, possible observations are:
|
||||
[-1.0, 1.0] and [1.0, -1.0], where the first element is the "current context".
|
||||
The highest reward (+10.0) is received for selecting arm 0 for context=1.0
|
||||
and arm 2 for context=-1.0. Action 1 always yields 0.0 reward.
|
||||
"""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.action_space = Discrete(3)
|
||||
self.observation_space = Box(low=-1.0, high=1.0, shape=(2,))
|
||||
self.cur_context = None
|
||||
|
||||
def reset(self, *, seed=None, options=None):
|
||||
self.cur_context = random.choice([-1.0, 1.0])
|
||||
return np.array([self.cur_context, -self.cur_context]), {}
|
||||
|
||||
def step(self, action):
|
||||
rewards_for_context = {
|
||||
-1.0: [-10, 0, 10],
|
||||
1.0: [10, 0, -10],
|
||||
}
|
||||
reward = rewards_for_context[self.cur_context][action]
|
||||
return (
|
||||
np.array([-self.cur_context, self.cur_context]),
|
||||
reward,
|
||||
True,
|
||||
False,
|
||||
{"regret": 10 - reward},
|
||||
)
|
||||
|
||||
|
||||
class LinearDiscreteEnv(gym.Env):
|
||||
"""Samples data from linearly parameterized arms.
|
||||
|
||||
The reward for context X and arm i is given by X^T * theta_i, for some
|
||||
latent set of parameters {theta_i : i = 1, ..., k}.
|
||||
The thetas are sampled uniformly at random, the contexts are Gaussian,
|
||||
and Gaussian noise is added to the rewards.
|
||||
"""
|
||||
|
||||
DEFAULT_CONFIG_LINEAR = {
|
||||
"feature_dim": 8,
|
||||
"num_actions": 4,
|
||||
"reward_noise_std": 0.01,
|
||||
}
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = copy.copy(self.DEFAULT_CONFIG_LINEAR)
|
||||
if config is not None and type(config) is dict:
|
||||
self.config.update(config)
|
||||
|
||||
self.feature_dim = self.config["feature_dim"]
|
||||
self.num_actions = self.config["num_actions"]
|
||||
self.sigma = self.config["reward_noise_std"]
|
||||
|
||||
self.action_space = Discrete(self.num_actions)
|
||||
self.observation_space = Box(low=-10, high=10, shape=(self.feature_dim,))
|
||||
|
||||
self.thetas = np.random.uniform(-1, 1, (self.num_actions, self.feature_dim))
|
||||
self.thetas /= np.linalg.norm(self.thetas, axis=1, keepdims=True)
|
||||
|
||||
self._elapsed_steps = 0
|
||||
self._current_context = None
|
||||
|
||||
def _sample_context(self):
|
||||
return np.random.normal(scale=1 / 3, size=(self.feature_dim,))
|
||||
|
||||
def reset(self, *, seed=None, options=None):
|
||||
self._current_context = self._sample_context()
|
||||
return self._current_context, {}
|
||||
|
||||
def step(self, action):
|
||||
assert (
|
||||
self._elapsed_steps is not None
|
||||
), "Cannot call env.step() beforecalling reset()"
|
||||
assert action < self.num_actions, "Invalid action."
|
||||
|
||||
action = int(action)
|
||||
context = self._current_context
|
||||
rewards = self.thetas.dot(context)
|
||||
|
||||
opt_action = rewards.argmax()
|
||||
|
||||
regret = rewards.max() - rewards[action]
|
||||
|
||||
# Add Gaussian noise
|
||||
rewards += np.random.normal(scale=self.sigma, size=rewards.shape)
|
||||
|
||||
reward = rewards[action]
|
||||
self._current_context = self._sample_context()
|
||||
return (
|
||||
self._current_context,
|
||||
reward,
|
||||
True,
|
||||
False,
|
||||
{"regret": regret, "opt_action": opt_action},
|
||||
)
|
||||
|
||||
def render(self, mode="human"):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class WheelBanditEnv(gym.Env):
|
||||
"""Wheel bandit environment for 2D contexts
|
||||
(see https://arxiv.org/abs/1802.09127).
|
||||
"""
|
||||
|
||||
DEFAULT_CONFIG_WHEEL = {
|
||||
"delta": 0.5,
|
||||
"mu_1": 1.2,
|
||||
"mu_2": 1,
|
||||
"mu_3": 50,
|
||||
"std": 0.01,
|
||||
}
|
||||
|
||||
feature_dim = 2
|
||||
num_actions = 5
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = copy.copy(self.DEFAULT_CONFIG_WHEEL)
|
||||
if config is not None and type(config) is dict:
|
||||
self.config.update(config)
|
||||
|
||||
self.delta = self.config["delta"]
|
||||
self.mu_1 = self.config["mu_1"]
|
||||
self.mu_2 = self.config["mu_2"]
|
||||
self.mu_3 = self.config["mu_3"]
|
||||
self.std = self.config["std"]
|
||||
|
||||
self.action_space = Discrete(self.num_actions)
|
||||
self.observation_space = Box(low=-1, high=1, shape=(self.feature_dim,))
|
||||
|
||||
self.means = [self.mu_1] + 4 * [self.mu_2]
|
||||
self._elapsed_steps = 0
|
||||
self._current_context = None
|
||||
|
||||
def _sample_context(self):
|
||||
while True:
|
||||
state = np.random.uniform(-1, 1, self.feature_dim)
|
||||
if np.linalg.norm(state) <= 1:
|
||||
return state
|
||||
|
||||
def reset(self, *, seed=None, options=None):
|
||||
self._current_context = self._sample_context()
|
||||
return self._current_context, {}
|
||||
|
||||
def step(self, action):
|
||||
assert (
|
||||
self._elapsed_steps is not None
|
||||
), "Cannot call env.step() before calling reset()"
|
||||
|
||||
action = int(action)
|
||||
self._elapsed_steps += 1
|
||||
rewards = [
|
||||
np.random.normal(self.means[j], self.std) for j in range(self.num_actions)
|
||||
]
|
||||
context = self._current_context
|
||||
r_big = np.random.normal(self.mu_3, self.std)
|
||||
|
||||
if np.linalg.norm(context) >= self.delta:
|
||||
if context[0] > 0:
|
||||
if context[1] > 0:
|
||||
# First quadrant
|
||||
rewards[1] = r_big
|
||||
opt_action = 1
|
||||
else:
|
||||
# Fourth quadrant
|
||||
rewards[4] = r_big
|
||||
opt_action = 4
|
||||
else:
|
||||
if context[1] > 0:
|
||||
# Second quadrant
|
||||
rewards[2] = r_big
|
||||
opt_action = 2
|
||||
else:
|
||||
# Third quadrant
|
||||
rewards[3] = r_big
|
||||
opt_action = 3
|
||||
else:
|
||||
# Smaller region where action 0 is optimal
|
||||
opt_action = 0
|
||||
|
||||
reward = rewards[action]
|
||||
|
||||
regret = rewards[opt_action] - reward
|
||||
|
||||
self._current_context = self._sample_context()
|
||||
return (
|
||||
self._current_context,
|
||||
reward,
|
||||
True,
|
||||
False,
|
||||
{"regret": regret, "opt_action": opt_action},
|
||||
)
|
||||
|
||||
def render(self, mode="human"):
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Examples for recommender system simulating envs ready to be used by RLlib Algorithms.
|
||||
|
||||
This env follows RecSim obs and action APIs.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
import gymnasium as gym
|
||||
import numpy as np
|
||||
|
||||
from ray.rllib.utils.numpy import softmax
|
||||
|
||||
|
||||
class ParametricRecSys(gym.Env):
|
||||
"""A recommendation environment which generates items with visible features
|
||||
randomly (parametric actions).
|
||||
The environment can be configured to be multi-user, i.e. different models
|
||||
will be learned independently for each user, by setting num_users_in_db
|
||||
parameter.
|
||||
To enable slate recommendation, the `slate_size` config parameter can be
|
||||
set as > 1.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
embedding_size: int = 20,
|
||||
num_docs_to_select_from: int = 10,
|
||||
slate_size: int = 1,
|
||||
num_docs_in_db: Optional[int] = None,
|
||||
num_users_in_db: Optional[int] = None,
|
||||
user_time_budget: float = 60.0,
|
||||
):
|
||||
"""Initializes a ParametricRecSys instance.
|
||||
|
||||
Args:
|
||||
embedding_size: Embedding size for both users and docs.
|
||||
Each value in the user/doc embeddings can have values between
|
||||
-1.0 and 1.0.
|
||||
num_docs_to_select_from: The number of documents to present to the
|
||||
agent each timestep. The agent will then have to pick a slate
|
||||
out of these.
|
||||
slate_size: The size of the slate to recommend to the user at each
|
||||
timestep.
|
||||
num_docs_in_db: The total number of documents in the DB. Set this
|
||||
to None, in case you would like to resample docs from an
|
||||
infinite pool.
|
||||
num_users_in_db: The total number of users in the DB. Set this to
|
||||
None, in case you would like to resample users from an infinite
|
||||
pool.
|
||||
user_time_budget: The total time budget a user has throughout an
|
||||
episode. Once this time budget is used up (through engagements
|
||||
with clicked/selected documents), the episode ends.
|
||||
"""
|
||||
self.embedding_size = embedding_size
|
||||
self.num_docs_to_select_from = num_docs_to_select_from
|
||||
self.slate_size = slate_size
|
||||
|
||||
self.num_docs_in_db = num_docs_in_db
|
||||
self.docs_db = None
|
||||
self.num_users_in_db = num_users_in_db
|
||||
self.users_db = None
|
||||
self.current_user = None
|
||||
|
||||
self.user_time_budget = user_time_budget
|
||||
self.current_user_budget = user_time_budget
|
||||
|
||||
self.observation_space = gym.spaces.Dict(
|
||||
{
|
||||
# The D docs our agent sees at each timestep.
|
||||
# It has to select a k-slate out of these.
|
||||
"doc": gym.spaces.Dict(
|
||||
{
|
||||
str(i): gym.spaces.Box(
|
||||
-1.0, 1.0, shape=(self.embedding_size,), dtype=np.float32
|
||||
)
|
||||
for i in range(self.num_docs_to_select_from)
|
||||
}
|
||||
),
|
||||
# The user engaging in this timestep/episode.
|
||||
"user": gym.spaces.Box(
|
||||
-1.0, 1.0, shape=(self.embedding_size,), dtype=np.float32
|
||||
),
|
||||
# For each item in the previous slate, was it clicked?
|
||||
# If yes, how long was it being engaged with (e.g. watched)?
|
||||
"response": gym.spaces.Tuple(
|
||||
[
|
||||
gym.spaces.Dict(
|
||||
{
|
||||
# Clicked or not?
|
||||
"click": gym.spaces.Discrete(2),
|
||||
# Engagement time (how many minutes watched?).
|
||||
"engagement": gym.spaces.Box(
|
||||
0.0, 100.0, shape=(), dtype=np.float32
|
||||
),
|
||||
}
|
||||
)
|
||||
for _ in range(self.slate_size)
|
||||
]
|
||||
),
|
||||
}
|
||||
)
|
||||
# Our action space is
|
||||
self.action_space = gym.spaces.MultiDiscrete(
|
||||
[self.num_docs_to_select_from for _ in range(self.slate_size)]
|
||||
)
|
||||
|
||||
def _get_embedding(self):
|
||||
return np.random.uniform(-1, 1, size=(self.embedding_size,)).astype(np.float32)
|
||||
|
||||
def reset(self, *, seed=None, options=None):
|
||||
# Reset the current user's time budget.
|
||||
self.current_user_budget = self.user_time_budget
|
||||
|
||||
# Sample a user for the next episode/session.
|
||||
# Pick from a only-once-sampled user DB.
|
||||
if self.num_users_in_db is not None:
|
||||
if self.users_db is None:
|
||||
self.users_db = [
|
||||
self._get_embedding() for _ in range(self.num_users_in_db)
|
||||
]
|
||||
self.current_user = self.users_db[np.random.choice(self.num_users_in_db)]
|
||||
# Pick from an infinite pool of users.
|
||||
else:
|
||||
self.current_user = self._get_embedding()
|
||||
|
||||
return self._get_obs(), {}
|
||||
|
||||
def step(self, action):
|
||||
# Action is the suggested slate (indices of the docs in the
|
||||
# suggested ones).
|
||||
|
||||
# We calculate scores as the dot product between document features and user
|
||||
# features. The softmax ensures regret<1 further down.
|
||||
scores = softmax(
|
||||
[np.dot(self.current_user, doc) for doc in self.currently_suggested_docs]
|
||||
)
|
||||
best_reward = np.max(scores)
|
||||
|
||||
# User choice model: User picks a doc stochastically,
|
||||
# where probs are dot products between user- and doc feature
|
||||
# (categories) vectors (rewards).
|
||||
# There is also a no-click doc whose weight is 0.0.
|
||||
user_doc_overlaps = np.array([scores[a] for a in action] + [0.0])
|
||||
# We have to softmax again so that probabilities add up to 1
|
||||
probabilities = softmax(user_doc_overlaps)
|
||||
which_clicked = np.random.choice(
|
||||
np.arange(self.slate_size + 1), p=probabilities
|
||||
)
|
||||
|
||||
reward = 0.0
|
||||
if which_clicked < self.slate_size:
|
||||
# Reward is 1.0 - regret if clicked. 0.0 if not clicked.
|
||||
regret = best_reward - user_doc_overlaps[which_clicked]
|
||||
# The reward also represents the user engagement that we define to be
|
||||
# withing the range [0...100].
|
||||
reward = (1 - regret) * 100
|
||||
# If anything clicked, deduct from the current user's time budget.
|
||||
self.current_user_budget -= 1.0
|
||||
done = truncated = self.current_user_budget <= 0.0
|
||||
|
||||
# Compile response.
|
||||
response = tuple(
|
||||
{
|
||||
"click": int(idx == which_clicked),
|
||||
"engagement": reward if idx == which_clicked else 0.0,
|
||||
}
|
||||
for idx in range(len(user_doc_overlaps) - 1)
|
||||
)
|
||||
|
||||
return self._get_obs(response=response), reward, done, truncated, {}
|
||||
|
||||
def _get_obs(self, response=None):
|
||||
# Sample D docs from infinity or our pre-existing docs.
|
||||
# Pick from a only-once-sampled docs DB.
|
||||
if self.num_docs_in_db is not None:
|
||||
if self.docs_db is None:
|
||||
self.docs_db = [
|
||||
self._get_embedding() for _ in range(self.num_docs_in_db)
|
||||
]
|
||||
self.currently_suggested_docs = [
|
||||
self.docs_db[doc_idx].astype(np.float32)
|
||||
for doc_idx in np.random.choice(
|
||||
self.num_docs_in_db,
|
||||
size=(self.num_docs_to_select_from,),
|
||||
replace=False,
|
||||
)
|
||||
]
|
||||
# Pick from an infinite pool of docs.
|
||||
else:
|
||||
self.currently_suggested_docs = [
|
||||
self._get_embedding() for _ in range(self.num_docs_to_select_from)
|
||||
]
|
||||
|
||||
doc = {str(i): d for i, d in enumerate(self.currently_suggested_docs)}
|
||||
|
||||
if not response:
|
||||
response = self.observation_space["response"].sample()
|
||||
|
||||
return {
|
||||
"user": self.current_user.astype(np.float32),
|
||||
"doc": doc,
|
||||
"response": response,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Test RecommSys env with random actions for baseline performance."""
|
||||
env = ParametricRecSys(
|
||||
num_docs_in_db=100,
|
||||
num_users_in_db=1,
|
||||
)
|
||||
obs, info = env.reset()
|
||||
num_episodes = 0
|
||||
episode_rewards = []
|
||||
episode_reward = 0.0
|
||||
|
||||
while num_episodes < 100:
|
||||
action = env.action_space.sample()
|
||||
obs, reward, done, truncated, _ = env.step(action)
|
||||
|
||||
episode_reward += reward
|
||||
if done:
|
||||
print(f"episode reward = {episode_reward}")
|
||||
env.reset()
|
||||
num_episodes += 1
|
||||
episode_rewards.append(episode_reward)
|
||||
episode_reward = 0.0
|
||||
|
||||
print(f"Avg reward={np.mean(episode_rewards)}")
|
||||
@@ -0,0 +1,146 @@
|
||||
import gymnasium as gym
|
||||
import numpy as np
|
||||
|
||||
from ray.rllib.env.multi_agent_env import MultiAgentEnv
|
||||
from ray.rllib.utils.annotations import override
|
||||
from ray.rllib.utils.typing import AgentID
|
||||
|
||||
|
||||
class DoubleRowCorridorEnv(MultiAgentEnv):
|
||||
"""A MultiAgentEnv with a single, global observation space for all agents.
|
||||
|
||||
There are two agents in this grid-world-style environment, `agent_0` and `agent_1`.
|
||||
The grid has two-rows and multiple columns and agents must, each
|
||||
separately, reach their individual goal position to receive a final reward of +10:
|
||||
|
||||
+---------------+
|
||||
|0 |
|
||||
| 1|
|
||||
+---------------+
|
||||
Legend:
|
||||
0 = agent_0 + goal state for agent_1
|
||||
1 = agent_1 + goal state for agent_0
|
||||
|
||||
You can change the length of the grid through providing the "length" key in the
|
||||
`config` dict passed to the env's constructor.
|
||||
|
||||
The action space for both agents is Discrete(4), which encodes to moving up, down,
|
||||
left, or right in the grid.
|
||||
|
||||
If the two agents collide, meaning they end up in the exact same field after both
|
||||
taking their actions at any timestep, an additional reward of +5 is given to both
|
||||
agents. Thus, optimal policies aim at seeking the respective other agent first, and
|
||||
only then proceeding to their agent's goal position.
|
||||
|
||||
Each agent in the env has an observation space of a 2-tuple containing its own
|
||||
x/y-position, where x is the row index, being either 0 (1st row) or 1 (2nd row),
|
||||
and y is the column index (starting from 0).
|
||||
"""
|
||||
|
||||
def __init__(self, config=None):
|
||||
super().__init__()
|
||||
|
||||
config = config or {}
|
||||
|
||||
self.length = config.get("length", 15)
|
||||
self.terminateds = {}
|
||||
self.collided = False
|
||||
|
||||
# Provide information about agents and possible agents.
|
||||
self.agents = self.possible_agents = ["agent_0", "agent_1"]
|
||||
self.terminateds = {}
|
||||
|
||||
# Observations: x/y, where the first number is the row index, the second number
|
||||
# is the column index, for both agents.
|
||||
# For example: [0.0, 2.0] means the agent is in row 0 and column 2.
|
||||
self._obs_spaces = gym.spaces.Box(
|
||||
0.0, self.length - 1, shape=(2,), dtype=np.int32
|
||||
)
|
||||
self._act_spaces = gym.spaces.Discrete(4)
|
||||
|
||||
@override(MultiAgentEnv)
|
||||
def reset(self, *, seed=None, options=None):
|
||||
self.agent_pos = {
|
||||
"agent_0": [0, 0],
|
||||
"agent_1": [1, self.length - 1],
|
||||
}
|
||||
self.goals = {
|
||||
"agent_0": [0, self.length - 1],
|
||||
"agent_1": [1, 0],
|
||||
}
|
||||
self.terminateds = {agent_id: False for agent_id in self.agent_pos}
|
||||
self.collided = False
|
||||
|
||||
return self._get_obs(), {}
|
||||
|
||||
@override(MultiAgentEnv)
|
||||
def step(self, action_dict):
|
||||
rewards = {
|
||||
agent_id: -0.1
|
||||
for agent_id in self.agent_pos
|
||||
if not self.terminateds[agent_id]
|
||||
}
|
||||
|
||||
for agent_id, action in action_dict.items():
|
||||
row, col = self.agent_pos[agent_id]
|
||||
|
||||
# up
|
||||
if action == 0 and row > 0:
|
||||
row -= 1
|
||||
# down
|
||||
elif action == 1 and row < 1:
|
||||
row += 1
|
||||
# left
|
||||
elif action == 2 and col > 0:
|
||||
col -= 1
|
||||
# right
|
||||
elif action == 3 and col < self.length - 1:
|
||||
col += 1
|
||||
|
||||
# Update positions.
|
||||
self.agent_pos[agent_id] = [row, col]
|
||||
|
||||
obs = self._get_obs()
|
||||
|
||||
# Check for collision (only if both agents are still active).
|
||||
if (
|
||||
not any(self.terminateds.values())
|
||||
and self.agent_pos["agent_0"] == self.agent_pos["agent_1"]
|
||||
):
|
||||
if not self.collided:
|
||||
rewards["agent_0"] += 5
|
||||
rewards["agent_1"] += 5
|
||||
self.collided = True
|
||||
|
||||
# Check goals.
|
||||
for agent_id in self.agent_pos:
|
||||
if (
|
||||
self.agent_pos[agent_id] == self.goals[agent_id]
|
||||
and not self.terminateds[agent_id]
|
||||
):
|
||||
rewards[agent_id] += 10
|
||||
self.terminateds[agent_id] = True
|
||||
|
||||
terminateds = {
|
||||
agent_id: self.terminateds[agent_id] for agent_id in self.agent_pos
|
||||
}
|
||||
terminateds["__all__"] = all(self.terminateds.values())
|
||||
|
||||
return obs, rewards, terminateds, {}, {}
|
||||
|
||||
@override(MultiAgentEnv)
|
||||
def get_observation_space(self, agent_id: AgentID) -> gym.Space:
|
||||
return self._obs_spaces
|
||||
|
||||
@override(MultiAgentEnv)
|
||||
def get_action_space(self, agent_id: AgentID) -> gym.Space:
|
||||
return self._act_spaces
|
||||
|
||||
def _get_obs(self):
|
||||
obs = {}
|
||||
pos = self.agent_pos
|
||||
for agent_id in self.agent_pos:
|
||||
if self.terminateds[agent_id]:
|
||||
continue
|
||||
obs[agent_id] = np.array(pos[agent_id], dtype=np.int32)
|
||||
return obs
|
||||
@@ -0,0 +1,10 @@
|
||||
# Footsies Environment
|
||||
|
||||
This environment implementation is based on the [FootsiesGym project](https://github.com/chasemcd/FootsiesGym),
|
||||
specifically the version as of **July 28, 2025**.
|
||||
|
||||
## Notes
|
||||
|
||||
All examples in the RLlib documentation that use the Footsies environment are self-contained.
|
||||
This means that you do not need to install anything from the FootsiesGym repository or other places.
|
||||
Examples handle binary automatically (downloading, extracting, starting, stopping, etc.).
|
||||
@@ -0,0 +1,226 @@
|
||||
import collections
|
||||
import copy
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.rllib.examples.envs.classes.multi_agent.footsies.game import constants
|
||||
from ray.rllib.examples.envs.classes.multi_agent.footsies.game.proto import (
|
||||
footsies_service_pb2 as footsies_pb2,
|
||||
)
|
||||
|
||||
|
||||
class FootsiesEncoder:
|
||||
"""Encoder class to generate observations from the game state"""
|
||||
|
||||
def __init__(self, observation_delay: int):
|
||||
self._encoding_history = {
|
||||
agent_id: collections.deque(maxlen=int(observation_delay))
|
||||
for agent_id in ["p1", "p2"]
|
||||
}
|
||||
self.observation_delay = observation_delay
|
||||
self._last_common_state: Optional[np.ndarray] = None
|
||||
self._action_id_values = list(constants.FOOTSIES_ACTION_IDS.values())
|
||||
|
||||
@staticmethod
|
||||
def encode_common_state(game_state: footsies_pb2.GameState) -> np.ndarray:
|
||||
p1_state, p2_state = game_state.player1, game_state.player2
|
||||
|
||||
dist_x = np.abs(p1_state.player_position_x - p2_state.player_position_x) / 8.0
|
||||
|
||||
return np.array(
|
||||
[
|
||||
dist_x,
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _encode_input_buffer(
|
||||
input_buffer: list[int], last_n: Optional[int] = None
|
||||
) -> np.ndarray:
|
||||
"""Encodes the input buffer into a one-hot vector.
|
||||
|
||||
:param input_buffer: The input buffer to encode
|
||||
:type input_buffer: list[int]
|
||||
:return: The encoded one-hot vector
|
||||
:rtype: np.ndarray
|
||||
"""
|
||||
|
||||
if last_n is not None:
|
||||
input_buffer = input_buffer[last_n:]
|
||||
|
||||
ib_encoding = []
|
||||
for action_id in input_buffer:
|
||||
arr = [0] * (len(constants.ACTION_TO_BITS) + 1)
|
||||
arr[action_id] = 1
|
||||
ib_encoding.extend(arr)
|
||||
|
||||
input_buffer_vector = np.asarray(ib_encoding, dtype=np.float32)
|
||||
|
||||
return input_buffer_vector
|
||||
|
||||
def encode(
|
||||
self,
|
||||
game_state: footsies_pb2.GameState,
|
||||
) -> dict[str, Any]:
|
||||
"""Encodes the game state into observations for all agents.
|
||||
|
||||
:param game_state: The game state to encode
|
||||
:type game_state: footsies_pb2.GameState
|
||||
:return: The encoded observations for all agents.
|
||||
:rtype: dict[str, Any]
|
||||
"""
|
||||
common_state = self.encode_common_state(game_state)
|
||||
p1_encoding = self.encode_player_state(game_state.player1)
|
||||
p2_encoding = self.encode_player_state(game_state.player2)
|
||||
|
||||
observation_delay = min(
|
||||
self.observation_delay, len(self._encoding_history["p1"])
|
||||
)
|
||||
|
||||
if observation_delay > 0:
|
||||
p1_delayed_encoding = self._encoding_history["p1"][-observation_delay]
|
||||
p2_delayed_encoding = self._encoding_history["p2"][-observation_delay]
|
||||
else:
|
||||
p1_delayed_encoding = copy.deepcopy(p1_encoding)
|
||||
p2_delayed_encoding = copy.deepcopy(p2_encoding)
|
||||
|
||||
self._encoding_history["p1"].append(p1_encoding)
|
||||
self._encoding_history["p2"].append(p2_encoding)
|
||||
self._last_common_state = common_state
|
||||
|
||||
# Create features dictionary
|
||||
features = {}
|
||||
current_index = 0
|
||||
|
||||
# Common state
|
||||
features["common_state"] = {
|
||||
"start": current_index,
|
||||
"length": len(common_state),
|
||||
}
|
||||
current_index += len(common_state)
|
||||
|
||||
# Concatenate the observations for the undelayed encoding
|
||||
p1_encoding = np.hstack(list(p1_encoding.values()), dtype=np.float32)
|
||||
p2_encoding = np.hstack(list(p2_encoding.values()), dtype=np.float32)
|
||||
|
||||
# Concatenate the observations for the delayed encoding
|
||||
p1_delayed_encoding = np.hstack(
|
||||
list(p1_delayed_encoding.values()), dtype=np.float32
|
||||
)
|
||||
p2_delayed_encoding = np.hstack(
|
||||
list(p2_delayed_encoding.values()), dtype=np.float32
|
||||
)
|
||||
|
||||
p1_centric_observation = np.hstack(
|
||||
[common_state, p1_encoding, p2_delayed_encoding]
|
||||
)
|
||||
|
||||
p2_centric_observation = np.hstack(
|
||||
[common_state, p2_encoding, p1_delayed_encoding]
|
||||
)
|
||||
|
||||
return {"p1": p1_centric_observation, "p2": p2_centric_observation}
|
||||
|
||||
def encode_player_state(
|
||||
self,
|
||||
player_state: footsies_pb2.PlayerState,
|
||||
) -> dict[str, Union[int, float, list, np.ndarray]]:
|
||||
"""Encodes the player state into observations.
|
||||
|
||||
:param player_state: The player state to encode
|
||||
:type player_state: footsies_pb2.PlayerState
|
||||
:return: The encoded observations for the player
|
||||
:rtype: dict[str, Any]
|
||||
"""
|
||||
feature_dict = {
|
||||
"player_position_x": player_state.player_position_x
|
||||
/ constants.FeatureDictNormalizers.PLAYER_POSITION_X,
|
||||
"velocity_x": player_state.velocity_x
|
||||
/ constants.FeatureDictNormalizers.VELOCITY_X,
|
||||
"is_dead": int(player_state.is_dead),
|
||||
"vital_health": player_state.vital_health,
|
||||
"guard_health": one_hot_encoder(player_state.guard_health, [0, 1, 2, 3]),
|
||||
"current_action_id": self._encode_action_id(player_state.current_action_id),
|
||||
"current_action_frame": player_state.current_action_frame
|
||||
/ constants.FeatureDictNormalizers.CURRENT_ACTION_FRAME,
|
||||
"current_action_frame_count": player_state.current_action_frame_count
|
||||
/ constants.FeatureDictNormalizers.CURRENT_ACTION_FRAME_COUNT,
|
||||
"current_action_remaining_frames": (
|
||||
player_state.current_action_frame_count
|
||||
- player_state.current_action_frame
|
||||
)
|
||||
/ constants.FeatureDictNormalizers.CURRENT_ACTION_REMAINING_FRAMES,
|
||||
"is_action_end": int(player_state.is_action_end),
|
||||
"is_always_cancelable": int(player_state.is_always_cancelable),
|
||||
"current_action_hit_count": player_state.current_action_hit_count,
|
||||
"current_hit_stun_frame": player_state.current_hit_stun_frame
|
||||
/ constants.FeatureDictNormalizers.CURRENT_HIT_STUN_FRAME,
|
||||
"is_in_hit_stun": int(player_state.is_in_hit_stun),
|
||||
"sprite_shake_position": player_state.sprite_shake_position,
|
||||
"max_sprite_shake_frame": player_state.max_sprite_shake_frame
|
||||
/ constants.FeatureDictNormalizers.MAX_SPRITE_SHAKE_FRAME,
|
||||
"is_face_right": int(player_state.is_face_right),
|
||||
"current_frame_advantage": player_state.current_frame_advantage
|
||||
/ constants.FeatureDictNormalizers.CURRENT_FRAME_ADVANTAGE,
|
||||
# The below features leak some information about the opponent!
|
||||
"would_next_forward_input_dash": int(
|
||||
player_state.would_next_forward_input_dash
|
||||
),
|
||||
"would_next_backward_input_dash": int(
|
||||
player_state.would_next_backward_input_dash
|
||||
),
|
||||
"special_attack_progress": min(player_state.special_attack_progress, 1.0),
|
||||
}
|
||||
|
||||
return feature_dict
|
||||
|
||||
def get_last_encoding(self) -> Optional[dict[str, np.ndarray]]:
|
||||
if self._last_common_state is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"common_state": self._last_common_state.reshape(-1),
|
||||
"p1": np.hstack(
|
||||
list(self._encoding_history["p1"][-1].values()),
|
||||
dtype=np.float32,
|
||||
),
|
||||
"p2": np.hstack(
|
||||
list(self._encoding_history["p2"][-1].values()),
|
||||
dtype=np.float32,
|
||||
),
|
||||
}
|
||||
|
||||
def reset(self):
|
||||
self._encoding_history = {
|
||||
agent_id: collections.deque(maxlen=int(self.observation_delay))
|
||||
for agent_id in ["p1", "p2"]
|
||||
}
|
||||
|
||||
def _encode_action_id(self, action_id: int) -> np.ndarray:
|
||||
"""Encodes the action id into a one-hot vector.
|
||||
|
||||
:param action_id: The action id to encode
|
||||
:type action_id: int
|
||||
:return: The encoded one-hot vector
|
||||
:rtype: np.ndarray
|
||||
"""
|
||||
|
||||
action_vector = np.zeros(len(self._action_id_values), dtype=np.float32)
|
||||
|
||||
# Get the index of the action id in constants.ActionID
|
||||
action_index = self._action_id_values.index(action_id)
|
||||
action_vector[action_index] = 1
|
||||
|
||||
assert action_vector.max() == 1 and action_vector.min() == 0
|
||||
|
||||
return action_vector
|
||||
|
||||
|
||||
def one_hot_encoder(
|
||||
value: Union[int, float, str], collection: list[Union[int, float, str]]
|
||||
) -> np.ndarray:
|
||||
vector = np.zeros(len(collection), dtype=np.float32)
|
||||
vector[collection.index(value)] = 1
|
||||
return vector
|
||||
@@ -0,0 +1,40 @@
|
||||
import tree # pip install dm_tree
|
||||
|
||||
from ray.rllib.core.rl_module import RLModule
|
||||
from ray.rllib.examples.envs.classes.multi_agent.footsies.game import constants
|
||||
from ray.rllib.policy import sample_batch
|
||||
from ray.rllib.utils.spaces.space_utils import batch as batch_func
|
||||
|
||||
|
||||
class FixedRLModule(RLModule):
|
||||
def _forward_inference(self, batch, **kwargs):
|
||||
return self._fixed_forward(batch, **kwargs)
|
||||
|
||||
def _forward_exploration(self, batch, **kwargs):
|
||||
return self._fixed_forward(batch, **kwargs)
|
||||
|
||||
def _forward_train(self, *args, **kwargs):
|
||||
raise NotImplementedError(
|
||||
f"RLlib: {self.__class__.__name__} should not be trained. "
|
||||
f"It is a fixed RLModule, returning a fixed action for all observations."
|
||||
)
|
||||
|
||||
def _fixed_forward(self, batch, **kwargs):
|
||||
"""Implements a fixed that always returns the same action."""
|
||||
raise NotImplementedError(
|
||||
"FixedRLModule: This method should be overridden by subclasses to implement a specific action."
|
||||
)
|
||||
|
||||
|
||||
class NoopFixedRLModule(FixedRLModule):
|
||||
def _fixed_forward(self, batch, **kwargs):
|
||||
obs_batch_size = len(tree.flatten(batch[sample_batch.SampleBatch.OBS])[0])
|
||||
actions = batch_func([constants.EnvActions.NONE for _ in range(obs_batch_size)])
|
||||
return {sample_batch.SampleBatch.ACTIONS: actions}
|
||||
|
||||
|
||||
class BackFixedRLModule(FixedRLModule):
|
||||
def _fixed_forward(self, batch, **kwargs):
|
||||
obs_batch_size = len(tree.flatten(batch[sample_batch.SampleBatch.OBS])[0])
|
||||
actions = batch_func([constants.EnvActions.BACK for _ in range(obs_batch_size)])
|
||||
return {sample_batch.SampleBatch.ACTIONS: actions}
|
||||
@@ -0,0 +1,284 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from gymnasium import spaces
|
||||
from pettingzoo.utils.env import (
|
||||
ActionType,
|
||||
AgentID,
|
||||
ObsType,
|
||||
)
|
||||
|
||||
from ray.rllib.env import EnvContext
|
||||
from ray.rllib.env.multi_agent_env import MultiAgentEnv
|
||||
from ray.rllib.examples.envs.classes.multi_agent.footsies.encoder import FootsiesEncoder
|
||||
from ray.rllib.examples.envs.classes.multi_agent.footsies.game import constants
|
||||
from ray.rllib.examples.envs.classes.multi_agent.footsies.game.footsies_binary import (
|
||||
FootsiesBinary,
|
||||
)
|
||||
from ray.rllib.examples.envs.classes.multi_agent.footsies.game.footsies_game import (
|
||||
FootsiesGame,
|
||||
)
|
||||
|
||||
import psutil
|
||||
|
||||
logger = logging.getLogger("ray.rllib")
|
||||
|
||||
|
||||
class FootsiesEnv(MultiAgentEnv):
|
||||
metadata = {"render.modes": ["human"]}
|
||||
SPECIAL_CHARGE_FRAMES = 60
|
||||
GUARD_BREAK_REWARD = 0.3
|
||||
|
||||
observation_space = spaces.Dict(
|
||||
{
|
||||
agent: spaces.Box(
|
||||
low=-np.inf,
|
||||
high=np.inf,
|
||||
shape=(constants.OBSERVATION_SPACE_SIZE,),
|
||||
)
|
||||
for agent in ["p1", "p2"]
|
||||
}
|
||||
)
|
||||
|
||||
action_space = spaces.Dict(
|
||||
{
|
||||
agent: spaces.Discrete(
|
||||
len(
|
||||
[
|
||||
constants.EnvActions.NONE,
|
||||
constants.EnvActions.BACK,
|
||||
constants.EnvActions.FORWARD,
|
||||
constants.EnvActions.ATTACK,
|
||||
constants.EnvActions.BACK_ATTACK,
|
||||
constants.EnvActions.FORWARD_ATTACK,
|
||||
# This is a special input that holds down
|
||||
# attack for 60 frames. It's just too long of a sequence
|
||||
# to easily learn by holding ATTACK for so long.
|
||||
constants.EnvActions.SPECIAL_CHARGE,
|
||||
]
|
||||
)
|
||||
)
|
||||
for agent in ["p1", "p2"]
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, config: EnvContext, port: int):
|
||||
super().__init__()
|
||||
|
||||
if config is None:
|
||||
config = {}
|
||||
self.config = config
|
||||
self.port = port
|
||||
self.footsies_process_pid = (
|
||||
None # Store PID of the running footsies process (we assume one per env)
|
||||
)
|
||||
self.agents: list[AgentID] = ["p1", "p2"]
|
||||
self.possible_agents: list[AgentID] = self.agents.copy()
|
||||
self._agent_ids: set[AgentID] = set(self.agents)
|
||||
|
||||
self.t: int = 0
|
||||
self.max_t: int = config.get("max_t", 1000)
|
||||
self.frame_skip = config.get("frame_skip", 4)
|
||||
observation_delay = config.get("observation_delay", 16)
|
||||
|
||||
assert (
|
||||
observation_delay % self.frame_skip == 0
|
||||
), "observation_delay must be divisible by frame_skip"
|
||||
|
||||
self.encoder = FootsiesEncoder(
|
||||
observation_delay=observation_delay // self.frame_skip
|
||||
)
|
||||
|
||||
# start the game server before initializing the communication between the
|
||||
# game server and the Python harness via gRPC
|
||||
self._prepare_and_start_game_server()
|
||||
self.game = FootsiesGame(
|
||||
host=config["host"],
|
||||
port=self.port,
|
||||
)
|
||||
|
||||
self.last_game_state = None
|
||||
self.special_charge_queue = {
|
||||
"p1": -1,
|
||||
"p2": -1,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _convert_to_charge_action(action: int) -> int:
|
||||
if action == constants.EnvActions.BACK:
|
||||
return constants.EnvActions.BACK_ATTACK
|
||||
elif action == constants.EnvActions.FORWARD:
|
||||
return constants.EnvActions.FORWARD_ATTACK
|
||||
else:
|
||||
return constants.EnvActions.ATTACK
|
||||
|
||||
def close(self):
|
||||
"""Terminate Footsies game server process.
|
||||
|
||||
Run to ensure no game servers are left running.
|
||||
"""
|
||||
timeout = 2
|
||||
try:
|
||||
logger.info(
|
||||
f"RLlib {self.__class__.__name__}: Terminating Footsies "
|
||||
f"game server process with PID: {self.footsies_process_pid}..."
|
||||
)
|
||||
p = psutil.Process(self.footsies_process_pid)
|
||||
p.terminate()
|
||||
p.wait(timeout=timeout)
|
||||
except psutil.NoSuchProcess:
|
||||
logger.info(
|
||||
f"RLlib {self.__class__.__name__}: Process with PID {self.footsies_process_pid} not found, "
|
||||
f"it might have been already terminated."
|
||||
)
|
||||
except psutil.TimeoutExpired:
|
||||
logger.warning(
|
||||
f"RLlib {self.__class__.__name__}: Process with PID {self.footsies_process_pid} did not terminate "
|
||||
f"within {timeout} seconds. "
|
||||
f"Sending SIGKILL signal instead.",
|
||||
)
|
||||
p.kill()
|
||||
p.wait(timeout=timeout)
|
||||
|
||||
def get_infos(self):
|
||||
return {agent: {} for agent in self.agents}
|
||||
|
||||
def get_obs(self, game_state):
|
||||
return self.encoder.encode(game_state)
|
||||
|
||||
def reset(
|
||||
self,
|
||||
*,
|
||||
seed: Optional[int] = None,
|
||||
options: Optional[dict] = None,
|
||||
) -> tuple[dict[AgentID, ObsType], dict[AgentID, Any]]:
|
||||
"""Resets the environment to the starting state
|
||||
and returns the initial observations for all agents.
|
||||
|
||||
:return: Tuple of observations and infos for each agent.
|
||||
:rtype: tuple[dict[AgentID, ObsType], dict[AgentID, Any]]
|
||||
"""
|
||||
self.t = 0
|
||||
self.game.reset_game()
|
||||
self.game.start_game()
|
||||
|
||||
self.encoder.reset()
|
||||
self.last_game_state = self.game.get_state()
|
||||
|
||||
observations = self.get_obs(self.last_game_state)
|
||||
|
||||
return observations, {agent: {} for agent in self.agents}
|
||||
|
||||
def step(
|
||||
self, actions: dict[AgentID, ActionType]
|
||||
) -> tuple[
|
||||
dict[AgentID, ObsType],
|
||||
dict[AgentID, float],
|
||||
dict[AgentID, bool],
|
||||
dict[AgentID, bool],
|
||||
dict[AgentID, dict[str, Any]],
|
||||
]:
|
||||
"""Step the environment with the provided actions for all agents.
|
||||
|
||||
:param actions: Dictionary mapping agent ids to their actions for this step.
|
||||
:type actions: dict[AgentID, ActionType]
|
||||
:return: Tuple of observations, rewards, terminates, truncateds and infos for all agents.
|
||||
:rtype: tuple[ dict[AgentID, ObsType], dict[AgentID, float], dict[AgentID, bool], dict[AgentID, bool], dict[AgentID, dict[str, Any]], ]
|
||||
"""
|
||||
self.t += 1
|
||||
|
||||
for agent_id in self.agents:
|
||||
empty_queue = self.special_charge_queue[agent_id] < 0
|
||||
action_is_special_charge = (
|
||||
actions[agent_id] == constants.EnvActions.SPECIAL_CHARGE
|
||||
)
|
||||
|
||||
# Refill the charge queue only if we're not already in a special charge.
|
||||
if action_is_special_charge and empty_queue:
|
||||
self.special_charge_queue[
|
||||
agent_id
|
||||
] = self._build_charged_special_queue()
|
||||
|
||||
if self.special_charge_queue[agent_id] >= 0:
|
||||
self.special_charge_queue[agent_id] -= 1
|
||||
actions[agent_id] = self._convert_to_charge_action(actions[agent_id])
|
||||
|
||||
p1_action = self.game.action_to_bits(actions["p1"], is_player_1=True)
|
||||
p2_action = self.game.action_to_bits(actions["p2"], is_player_1=False)
|
||||
|
||||
game_state = self.game.step_n_frames(
|
||||
p1_action=p1_action, p2_action=p2_action, n_frames=self.frame_skip
|
||||
)
|
||||
observations = self.get_obs(game_state)
|
||||
|
||||
terminated = game_state.player1.is_dead or game_state.player2.is_dead
|
||||
|
||||
# Zero-sum game: 1 if other player is dead, -1 if you're dead:
|
||||
rewards = {
|
||||
"p1": int(game_state.player2.is_dead) - int(game_state.player1.is_dead),
|
||||
"p2": int(game_state.player1.is_dead) - int(game_state.player2.is_dead),
|
||||
}
|
||||
|
||||
if self.config.get("reward_guard_break", False):
|
||||
p1_prev_guard_health = self.last_game_state.player1.guard_health
|
||||
p2_prev_guard_health = self.last_game_state.player2.guard_health
|
||||
p1_guard_health = game_state.player1.guard_health
|
||||
p2_guard_health = game_state.player2.guard_health
|
||||
|
||||
if p2_guard_health < p2_prev_guard_health:
|
||||
rewards["p1"] += self.GUARD_BREAK_REWARD
|
||||
rewards["p2"] -= self.GUARD_BREAK_REWARD
|
||||
if p1_guard_health < p1_prev_guard_health:
|
||||
rewards["p2"] += self.GUARD_BREAK_REWARD
|
||||
rewards["p1"] -= self.GUARD_BREAK_REWARD
|
||||
|
||||
terminateds = {
|
||||
"p1": terminated,
|
||||
"p2": terminated,
|
||||
"__all__": terminated,
|
||||
}
|
||||
|
||||
truncated = self.t >= self.max_t
|
||||
truncateds = {
|
||||
"p1": truncated,
|
||||
"p2": truncated,
|
||||
"__all__": truncated,
|
||||
}
|
||||
|
||||
self.last_game_state = game_state
|
||||
|
||||
return observations, rewards, terminateds, truncateds, self.get_infos()
|
||||
|
||||
def _build_charged_special_queue(self):
|
||||
assert self.SPECIAL_CHARGE_FRAMES % self.frame_skip == 0
|
||||
steps_to_apply_attack = int(self.SPECIAL_CHARGE_FRAMES // self.frame_skip)
|
||||
return steps_to_apply_attack
|
||||
|
||||
def _prepare_and_start_game_server(self):
|
||||
fb = FootsiesBinary(config=self.config, port=self.port)
|
||||
self.footsies_process_pid = fb.start_game_server()
|
||||
|
||||
|
||||
def env_creator(env_config: EnvContext) -> FootsiesEnv:
|
||||
"""Creates the Footsies environment
|
||||
|
||||
Ensure that each game server runs on a unique port. Training and evaluation env runners have separate port ranges.
|
||||
|
||||
Helper function to create the FootsiesEnv with a unique port based on the worker index and vector index.
|
||||
It's usually passed to the `register_env()`, like this: register_env(name="FootsiesEnv", env_creator=env_creator).
|
||||
"""
|
||||
if env_config.get("env-for-evaluation", False):
|
||||
port = (
|
||||
env_config["eval_start_port"]
|
||||
- 1 # "-1" to start with eval_start_port as the first port (eval worker index starts at 1)
|
||||
+ int(env_config.worker_index) * env_config.get("num_envs_per_worker", 1)
|
||||
+ env_config.get("vector_index", 0)
|
||||
)
|
||||
else:
|
||||
port = (
|
||||
env_config["train_start_port"]
|
||||
+ int(env_config.worker_index) * env_config.get("num_envs_per_worker", 1)
|
||||
+ env_config.get("vector_index", 0)
|
||||
)
|
||||
return FootsiesEnv(config=env_config, port=port)
|
||||
@@ -0,0 +1,151 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
OBSERVATION_SPACE_SIZE: int = 81
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvActions:
|
||||
NONE = 0
|
||||
BACK = 1
|
||||
FORWARD = 2
|
||||
ATTACK = 3
|
||||
BACK_ATTACK = 4
|
||||
FORWARD_ATTACK = 5
|
||||
SPECIAL_CHARGE = 6
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameActions:
|
||||
NONE = 0
|
||||
LEFT = 1
|
||||
RIGHT = 2
|
||||
ATTACK = 3
|
||||
LEFT_ATTACK = 4
|
||||
RIGHT_ATTACK = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionBits:
|
||||
NONE: int = 0
|
||||
LEFT: int = 1 << 0
|
||||
RIGHT: int = 1 << 1
|
||||
ATTACK: int = 1 << 2
|
||||
LEFT_ATTACK: int = LEFT | ATTACK
|
||||
RIGHT_ATTACK: int = RIGHT | ATTACK
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionID:
|
||||
STAND = 0
|
||||
FORWARD = 1
|
||||
BACKWARD = 2
|
||||
DASH_FORWARD = 10
|
||||
DASH_BACKWARD = 11
|
||||
N_ATTACK = 100
|
||||
B_ATTACK = 105
|
||||
N_SPECIAL = 110
|
||||
B_SPECIAL = 115
|
||||
DAMAGE = 200
|
||||
GUARD_M = 301
|
||||
GUARD_STAND = 305
|
||||
GUARD_CROUCH = 306
|
||||
GUARD_BREAK = 310
|
||||
GUARD_PROXIMITY = 350
|
||||
DEAD = 500
|
||||
WIN = 510
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureDictNormalizers:
|
||||
PLAYER_POSITION_X = 4.0
|
||||
VELOCITY_X = 5.0
|
||||
CURRENT_ACTION_FRAME = 25
|
||||
CURRENT_ACTION_FRAME_COUNT = 25
|
||||
CURRENT_ACTION_REMAINING_FRAMES = 25
|
||||
CURRENT_HIT_STUN_FRAME = 10
|
||||
MAX_SPRITE_SHAKE_FRAME = 10
|
||||
CURRENT_FRAME_ADVANTAGE = 10
|
||||
|
||||
|
||||
ACTION_TO_BITS = {
|
||||
GameActions.NONE: ActionBits.NONE,
|
||||
GameActions.LEFT: ActionBits.LEFT,
|
||||
GameActions.RIGHT: ActionBits.RIGHT,
|
||||
GameActions.ATTACK: ActionBits.ATTACK,
|
||||
GameActions.LEFT_ATTACK: ActionBits.LEFT_ATTACK,
|
||||
GameActions.RIGHT_ATTACK: ActionBits.RIGHT_ATTACK,
|
||||
}
|
||||
|
||||
FOOTSIES_ACTION_IDS = {
|
||||
"STAND": ActionID.STAND,
|
||||
"FORWARD": ActionID.FORWARD,
|
||||
"BACKWARD": ActionID.BACKWARD,
|
||||
"DASH_FORWARD": ActionID.DASH_FORWARD,
|
||||
"DASH_BACKWARD": ActionID.DASH_BACKWARD,
|
||||
"N_ATTACK": ActionID.N_ATTACK,
|
||||
"B_ATTACK": ActionID.B_ATTACK,
|
||||
"N_SPECIAL": ActionID.N_SPECIAL,
|
||||
"B_SPECIAL": ActionID.B_SPECIAL,
|
||||
"DAMAGE": ActionID.DAMAGE,
|
||||
"GUARD_M": ActionID.GUARD_M,
|
||||
"GUARD_STAND": ActionID.GUARD_STAND,
|
||||
"GUARD_CROUCH": ActionID.GUARD_CROUCH,
|
||||
"GUARD_BREAK": ActionID.GUARD_BREAK,
|
||||
"GUARD_PROXIMITY": ActionID.GUARD_PROXIMITY,
|
||||
"DEAD": ActionID.DEAD,
|
||||
"WIN": ActionID.WIN,
|
||||
}
|
||||
|
||||
# backup file location (uploaded July 29th, 2025):
|
||||
# https://ray-example-data.s3.us-west-2.amazonaws.com/rllib/env-footsies/feature_indices.json
|
||||
# Dictionary mapping feature names to their index ranges within a flat observation vector.
|
||||
# Each key is a feature name, and its value is a dictionary with keys:
|
||||
# "start": the starting index in the observation array.
|
||||
# "length": it's length in bytes
|
||||
feature_indices = {
|
||||
"common_state": {"start": 0, "length": 1},
|
||||
"frame_count": {"start": 1, "length": 1},
|
||||
"player_position_x": {"start": 2, "length": 1},
|
||||
"velocity_x": {"start": 3, "length": 1},
|
||||
"is_dead": {"start": 4, "length": 1},
|
||||
"vital_health": {"start": 5, "length": 1},
|
||||
"guard_health": {"start": 6, "length": 4},
|
||||
"current_action_id": {"start": 10, "length": 17},
|
||||
"current_action_frame": {"start": 27, "length": 1},
|
||||
"current_action_frame_count": {"start": 28, "length": 1},
|
||||
"current_action_remaining_frames": {"start": 29, "length": 1},
|
||||
"is_action_end": {"start": 30, "length": 1},
|
||||
"is_always_cancelable": {"start": 31, "length": 1},
|
||||
"current_action_hit_count": {"start": 32, "length": 1},
|
||||
"current_hit_stun_frame": {"start": 33, "length": 1},
|
||||
"is_in_hit_stun": {"start": 34, "length": 1},
|
||||
"sprite_shake_position": {"start": 35, "length": 1},
|
||||
"max_sprite_shake_frame": {"start": 36, "length": 1},
|
||||
"is_face_right": {"start": 37, "length": 1},
|
||||
"current_frame_advantage": {"start": 38, "length": 1},
|
||||
"would_next_forward_input_dash": {"start": 39, "length": 1},
|
||||
"would_next_backward_input_dash": {"start": 40, "length": 1},
|
||||
"special_attack_progress": {"start": 41, "length": 1},
|
||||
"opponent_frame_count": {"start": 42, "length": 1},
|
||||
"opponent_player_position_x": {"start": 43, "length": 1},
|
||||
"opponent_velocity_x": {"start": 44, "length": 1},
|
||||
"opponent_is_dead": {"start": 45, "length": 1},
|
||||
"opponent_vital_health": {"start": 46, "length": 1},
|
||||
"opponent_guard_health": {"start": 47, "length": 4},
|
||||
"opponent_current_action_id": {"start": 51, "length": 17},
|
||||
"opponent_current_action_frame": {"start": 68, "length": 1},
|
||||
"opponent_current_action_frame_count": {"start": 69, "length": 1},
|
||||
"opponent_current_action_remaining_frames": {"start": 70, "length": 1},
|
||||
"opponent_is_action_end": {"start": 71, "length": 1},
|
||||
"opponent_is_always_cancelable": {"start": 72, "length": 1},
|
||||
"opponent_current_action_hit_count": {"start": 73, "length": 1},
|
||||
"opponent_current_hit_stun_frame": {"start": 74, "length": 1},
|
||||
"opponent_is_in_hit_stun": {"start": 75, "length": 1},
|
||||
"opponent_sprite_shake_position": {"start": 76, "length": 1},
|
||||
"opponent_max_sprite_shake_frame": {"start": 77, "length": 1},
|
||||
"opponent_is_face_right": {"start": 78, "length": 1},
|
||||
"opponent_current_frame_advantage": {"start": 79, "length": 1},
|
||||
"opponent_would_next_forward_input_dash": {"start": 80, "length": 1},
|
||||
"opponent_would_next_backward_input_dash": {"start": 81, "length": 1},
|
||||
"opponent_special_attack_progress": {"start": 82, "length": 1},
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import grpc
|
||||
import requests
|
||||
from filelock import FileLock
|
||||
|
||||
from ray.rllib.env import EnvContext
|
||||
from ray.rllib.examples.envs.classes.multi_agent.footsies.game.proto import (
|
||||
footsies_service_pb2 as footsies_pb2,
|
||||
footsies_service_pb2_grpc as footsies_pb2_grpc,
|
||||
)
|
||||
from ray.util import log_once
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BinaryUrls:
|
||||
# Uploaded 07.28.2025
|
||||
S3_ROOT = "https://ray-example-data.s3.us-west-2.amazonaws.com/rllib/env-footsies/binaries/"
|
||||
|
||||
# Zip file names
|
||||
ZIP_LINUX_SERVER = "footsies_linux_server_021725.zip"
|
||||
ZIP_LINUX_WINDOWED = "footsies_linux_windowed_021725.zip"
|
||||
ZIP_MAC_HEADLESS = "footsies_mac_headless_5709b6d.zip"
|
||||
ZIP_MAC_WINDOWED = "footsies_mac_windowed_5709b6d.zip"
|
||||
|
||||
# Full URLs
|
||||
URL_LINUX_SERVER_BINARIES = S3_ROOT + ZIP_LINUX_SERVER
|
||||
URL_LINUX_WINDOWED_BINARIES = S3_ROOT + ZIP_LINUX_WINDOWED
|
||||
URL_MAC_HEADLESS_BINARIES = S3_ROOT + ZIP_MAC_HEADLESS
|
||||
URL_MAC_WINDOWED_BINARIES = S3_ROOT + ZIP_MAC_WINDOWED
|
||||
|
||||
|
||||
class FootsiesBinary:
|
||||
def __init__(self, config: EnvContext, port: int):
|
||||
self._urls = BinaryUrls()
|
||||
self.config = config
|
||||
self.port = port
|
||||
self.binary_to_download = config["binary_to_download"]
|
||||
|
||||
if self.binary_to_download == "linux_server":
|
||||
self.url = self._urls.URL_LINUX_SERVER_BINARIES
|
||||
elif self.binary_to_download == "linux_windowed":
|
||||
self.url = self._urls.URL_LINUX_WINDOWED_BINARIES
|
||||
elif self.binary_to_download == "mac_headless":
|
||||
self.url = self._urls.URL_MAC_HEADLESS_BINARIES
|
||||
elif self.binary_to_download == "mac_windowed":
|
||||
self.url = self._urls.URL_MAC_WINDOWED_BINARIES
|
||||
else:
|
||||
raise ValueError(f"Invalid target binary: {self.binary_to_download}")
|
||||
|
||||
self.full_download_dir = Path(config["binary_download_dir"]).resolve()
|
||||
self.full_download_path = (
|
||||
self.full_download_dir / str.split(self.url, sep="/")[-1]
|
||||
)
|
||||
self.full_extract_dir = Path(config["binary_extract_dir"]).resolve()
|
||||
self.renamed_path = self.full_extract_dir / "footsies_binaries"
|
||||
|
||||
@staticmethod
|
||||
def _add_executable_permission(binary_path: Path) -> None:
|
||||
binary_path.chmod(binary_path.stat().st_mode | stat.S_IXUSR)
|
||||
|
||||
def start_game_server(self) -> int:
|
||||
"""Downloads, unzips, and starts the Footsies game server binary.
|
||||
|
||||
Returns footsies process PID.
|
||||
"""
|
||||
self._download_game_binary()
|
||||
self._unzip_game_binary()
|
||||
|
||||
if self.binary_to_download == "mac_windowed":
|
||||
game_binary_path = (
|
||||
Path(self.renamed_path) / "Contents" / "MacOS" / "FOOTSIES"
|
||||
)
|
||||
elif self.binary_to_download == "mac_headless":
|
||||
game_binary_path = Path(self.renamed_path) / "FOOTSIES"
|
||||
else:
|
||||
game_binary_path = Path(self.renamed_path) / "footsies.x86_64"
|
||||
|
||||
if os.access(game_binary_path, os.X_OK):
|
||||
logger.info(
|
||||
f"Game binary has an 'executable' permission: {game_binary_path}"
|
||||
)
|
||||
else:
|
||||
self._add_executable_permission(game_binary_path)
|
||||
logger.info(f"Game binary path: {game_binary_path}")
|
||||
|
||||
# The underlying game can be quite spammy. So when we are not debugging it, we can suppress the output.
|
||||
log_output = self.config.get("log_unity_output")
|
||||
if log_output is None:
|
||||
if log_once("log_unity_output_not_set"):
|
||||
logger.warning(
|
||||
"`log_unity_output` not set in environment config, not logging output by default"
|
||||
)
|
||||
log_output = False
|
||||
|
||||
if not log_output:
|
||||
stdout_dest = stderr_dest = subprocess.DEVNULL
|
||||
else:
|
||||
stdout_dest = stderr_dest = None # Use parent's stdout/stderr
|
||||
|
||||
if (
|
||||
self.binary_to_download == "linux_server"
|
||||
or self.binary_to_download == "linux_windowed"
|
||||
):
|
||||
process = subprocess.Popen(
|
||||
[game_binary_path, "--port", str(self.port)],
|
||||
stdout=stdout_dest,
|
||||
stderr=stderr_dest,
|
||||
)
|
||||
else:
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
"arch",
|
||||
"-x86_64",
|
||||
game_binary_path,
|
||||
"--port",
|
||||
str(self.port),
|
||||
],
|
||||
stdout=stdout_dest,
|
||||
stderr=stderr_dest,
|
||||
)
|
||||
|
||||
# check if the game server is running correctly
|
||||
timeout = 2
|
||||
channel = grpc.insecure_channel(f"localhost:{self.port}")
|
||||
stub = footsies_pb2_grpc.FootsiesGameServiceStub(channel)
|
||||
|
||||
# step 1: try to start the game
|
||||
while True:
|
||||
try:
|
||||
stub.StartGame(footsies_pb2.Empty())
|
||||
logger.info("Game ready!")
|
||||
break
|
||||
except grpc.RpcError as e:
|
||||
code = e.code()
|
||||
if code in (
|
||||
grpc.StatusCode.UNAVAILABLE,
|
||||
grpc.StatusCode.DEADLINE_EXCEEDED,
|
||||
):
|
||||
logger.info(f"RLlib {self.__class__.__name__}: Game not ready...")
|
||||
time.sleep(timeout)
|
||||
continue
|
||||
raise
|
||||
|
||||
# step 2: check if the game is ready
|
||||
ready = False
|
||||
while not ready:
|
||||
try:
|
||||
ready = stub.IsReady(footsies_pb2.Empty()).value
|
||||
if not ready:
|
||||
logger.info(f"RLlib {self.__class__.__name__}: Game not ready...")
|
||||
time.sleep(timeout)
|
||||
continue
|
||||
else:
|
||||
logger.info("Game ready!")
|
||||
break
|
||||
except grpc.RpcError as e:
|
||||
if e.code() in (
|
||||
grpc.StatusCode.UNAVAILABLE,
|
||||
grpc.StatusCode.DEADLINE_EXCEEDED,
|
||||
):
|
||||
time.sleep(timeout)
|
||||
logger.info(f"RLlib {self.__class__.__name__}: Game not ready...")
|
||||
continue
|
||||
raise
|
||||
|
||||
channel.close()
|
||||
return process.pid
|
||||
|
||||
def _download_game_binary(self):
|
||||
# As multiple actors might try to download all at the same time.
|
||||
# The file lock should force only one actor to download
|
||||
chunk_size = 1024 * 1024 # 1MB
|
||||
|
||||
lock_path = self.full_download_path.parent / ".footsies-download.lock"
|
||||
with FileLock(lock_path, timeout=300):
|
||||
if self.full_download_path.exists():
|
||||
logger.info(
|
||||
f"Game binary already exists at {self.full_download_path}, skipping download."
|
||||
)
|
||||
|
||||
else:
|
||||
try:
|
||||
with requests.get(self.url, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
self.full_download_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.full_download_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
logger.info(
|
||||
f"Downloaded game binary to {self.full_download_path}\n"
|
||||
f"Binary size: {self.full_download_path.stat().st_size / 1024 / 1024:.1f} MB\n"
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to download binary from {self.url}: {e}")
|
||||
|
||||
def _unzip_game_binary(self):
|
||||
# As multiple actors might try to unzip or rename the paths at the same time.
|
||||
# The file lock should force this function to be sequential
|
||||
lock_path = self.full_download_path.parent / ".footsies-unzip.lock"
|
||||
with FileLock(lock_path, timeout=300):
|
||||
if self.renamed_path.exists():
|
||||
logger.info(
|
||||
f"Game binary already extracted at {self.renamed_path}, skipping extraction."
|
||||
)
|
||||
else:
|
||||
self.full_extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(self.full_download_path, mode="r") as zip_ref:
|
||||
zip_ref.extractall(self.full_extract_dir)
|
||||
|
||||
if self.binary_to_download == "mac_windowed":
|
||||
self.full_download_path.with_suffix(".app").rename(
|
||||
self.renamed_path
|
||||
)
|
||||
else:
|
||||
self.full_download_path.with_suffix("").rename(self.renamed_path)
|
||||
logger.info(f"Extracted game binary to {self.renamed_path}")
|
||||
@@ -0,0 +1,121 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import grpc
|
||||
import numpy as np
|
||||
|
||||
import ray.rllib.examples.envs.classes.multi_agent.footsies.game.proto.footsies_service_pb2 as footsies_pb2
|
||||
import ray.rllib.examples.envs.classes.multi_agent.footsies.game.proto.footsies_service_pb2_grpc as footsies_pb2_grpc
|
||||
from ray.rllib.examples.envs.classes.multi_agent.footsies.game import constants
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FootsiesGame:
|
||||
"""Handles gRPC communication with game the server.
|
||||
|
||||
This class establishes communication between the
|
||||
game server and the Python harness via gRPC. It provides methods
|
||||
to start the game, reset it, get the current state, and step the
|
||||
game by a certain number of frames.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.stub = self._initialize_stub()
|
||||
|
||||
@staticmethod
|
||||
def action_to_bits(action: int, is_player_1: bool) -> int:
|
||||
"""Converts an action to its corresponding bit representation."""
|
||||
|
||||
if isinstance(action, np.ndarray):
|
||||
action = action.item()
|
||||
|
||||
if is_player_1:
|
||||
if action == constants.EnvActions.BACK:
|
||||
action = constants.GameActions.LEFT
|
||||
elif action == constants.EnvActions.FORWARD:
|
||||
action = constants.GameActions.RIGHT
|
||||
elif action == constants.EnvActions.BACK_ATTACK:
|
||||
action = constants.GameActions.LEFT_ATTACK
|
||||
elif action == constants.EnvActions.FORWARD_ATTACK:
|
||||
action = constants.GameActions.RIGHT_ATTACK
|
||||
else:
|
||||
if action == constants.EnvActions.BACK:
|
||||
action = constants.GameActions.RIGHT
|
||||
elif action == constants.EnvActions.FORWARD:
|
||||
action = constants.GameActions.LEFT
|
||||
elif action == constants.EnvActions.BACK_ATTACK:
|
||||
action = constants.GameActions.RIGHT_ATTACK
|
||||
elif action == constants.EnvActions.FORWARD_ATTACK:
|
||||
action = constants.GameActions.LEFT_ATTACK
|
||||
|
||||
return constants.ACTION_TO_BITS[action]
|
||||
|
||||
def get_encoded_state(self) -> footsies_pb2.EncodedGameState:
|
||||
"""Gets the current encoded game state by calling the GetEncodedState RPC."""
|
||||
try:
|
||||
return self.stub.GetEncodedState(footsies_pb2.Empty())
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling GetEncodedState with exception: {e}")
|
||||
raise e
|
||||
|
||||
def get_state(self) -> footsies_pb2.GameState:
|
||||
"""Gets the current game state by calling the GetState RPC."""
|
||||
try:
|
||||
return self.stub.GetState(footsies_pb2.Empty())
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling GetState with exception: {e}")
|
||||
raise e
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
"""Checks if the game is ready by calling the IsReady RPC."""
|
||||
try:
|
||||
return self.stub.IsReady(footsies_pb2.Empty()).value
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling IsReady with exception: {e}")
|
||||
raise e
|
||||
|
||||
def reset_game(self) -> None:
|
||||
"""Resets the game by calling the ResetGame RPC."""
|
||||
try:
|
||||
self.stub.ResetGame(footsies_pb2.Empty())
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling ResetGame with exception: {e}")
|
||||
raise e
|
||||
|
||||
def start_game(self) -> None:
|
||||
"""Starts the game by calling the StartGame RPC."""
|
||||
try:
|
||||
self.stub.StartGame(footsies_pb2.Empty())
|
||||
|
||||
while not self.is_ready():
|
||||
logger.info("Game not ready...")
|
||||
time.sleep(0.5)
|
||||
logger.info("StartGame called successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling StartGame with exception: {e}")
|
||||
raise e
|
||||
|
||||
def step_n_frames(
|
||||
self, p1_action: int, p2_action: int, n_frames: int
|
||||
) -> footsies_pb2.GameState:
|
||||
"""Steps the game by n_frames with the given player actions. The provided actions will be repeated for all n_frames."""
|
||||
try:
|
||||
step_input = footsies_pb2.StepInput(
|
||||
p1_action=p1_action, p2_action=p2_action, nFrames=n_frames
|
||||
)
|
||||
return self.stub.StepNFrames(step_input)
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling StepNFrames with exception: {e}")
|
||||
raise e
|
||||
|
||||
def _initialize_stub(self) -> footsies_pb2_grpc.FootsiesGameServiceStub:
|
||||
try:
|
||||
channel = grpc.insecure_channel(f"{self.host}:{self.port}")
|
||||
return footsies_pb2_grpc.FootsiesGameServiceStub(channel)
|
||||
except grpc.RpcError as e:
|
||||
logger.error(f"Error connecting to gRPC stub with exception: {e}")
|
||||
raise e
|
||||
@@ -0,0 +1,63 @@
|
||||
syntax = "proto3";
|
||||
|
||||
|
||||
service FootsiesGameService {
|
||||
rpc StartGame(Empty) returns (Empty) {}
|
||||
rpc ResetGame(Empty) returns (Empty) {}
|
||||
rpc StepNFrames(StepInput) returns (GameState) {}
|
||||
rpc GetState(Empty) returns (GameState) {}
|
||||
rpc GetEncodedState(Empty) returns (EncodedGameState) {}
|
||||
rpc IsReady(Empty) returns (BoolValue) {}
|
||||
}
|
||||
|
||||
|
||||
message StepInput {
|
||||
int64 p1_action = 1;
|
||||
int64 p2_action = 2;
|
||||
int64 nFrames = 3;
|
||||
}
|
||||
|
||||
message PlayerState {
|
||||
float player_position_x = 1;
|
||||
bool is_dead = 2;
|
||||
int64 vital_health = 3;
|
||||
int64 guard_health = 4;
|
||||
int64 current_action_id = 5;
|
||||
int64 current_action_frame = 6;
|
||||
int64 current_action_frame_count = 7;
|
||||
bool is_action_end = 8;
|
||||
bool is_always_cancelable = 9;
|
||||
int64 current_action_hit_count = 10;
|
||||
int64 current_hit_stun_frame = 11;
|
||||
bool is_in_hit_stun = 12;
|
||||
int64 sprite_shake_position = 13;
|
||||
int64 max_sprite_shake_frame = 14;
|
||||
float velocity_x = 15;
|
||||
bool is_face_right = 16;
|
||||
repeated int64 input_buffer = 17;
|
||||
int64 current_frame_advantage = 18;
|
||||
bool would_next_forward_input_dash = 19;
|
||||
bool would_next_backward_input_dash = 20;
|
||||
float special_attack_progress = 21;
|
||||
}
|
||||
|
||||
message GameState {
|
||||
PlayerState player1 = 1;
|
||||
PlayerState player2 = 2;
|
||||
int64 round_state = 3;
|
||||
int64 frame_count = 4;
|
||||
}
|
||||
|
||||
message EncodedGameState {
|
||||
repeated float player1_encoding = 1;
|
||||
repeated float player2_encoding = 2;
|
||||
}
|
||||
|
||||
message BoolValue {
|
||||
bool value = 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
message Empty {}
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
import ray.rllib.examples.envs.classes.multi_agent.footsies.game.proto.footsies_service_pb2 as footsies__service__pb2
|
||||
|
||||
|
||||
# import footsies_service_pb2 as footsies__service__pb2
|
||||
|
||||
|
||||
class FootsiesGameServiceStub(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.StartGame = channel.unary_unary(
|
||||
"/FootsiesGameService/StartGame",
|
||||
request_serializer=footsies__service__pb2.Empty.SerializeToString,
|
||||
response_deserializer=footsies__service__pb2.Empty.FromString,
|
||||
)
|
||||
self.ResetGame = channel.unary_unary(
|
||||
"/FootsiesGameService/ResetGame",
|
||||
request_serializer=footsies__service__pb2.Empty.SerializeToString,
|
||||
response_deserializer=footsies__service__pb2.Empty.FromString,
|
||||
)
|
||||
self.StepNFrames = channel.unary_unary(
|
||||
"/FootsiesGameService/StepNFrames",
|
||||
request_serializer=footsies__service__pb2.StepInput.SerializeToString,
|
||||
response_deserializer=footsies__service__pb2.GameState.FromString,
|
||||
)
|
||||
self.GetState = channel.unary_unary(
|
||||
"/FootsiesGameService/GetState",
|
||||
request_serializer=footsies__service__pb2.Empty.SerializeToString,
|
||||
response_deserializer=footsies__service__pb2.GameState.FromString,
|
||||
)
|
||||
self.GetEncodedState = channel.unary_unary(
|
||||
"/FootsiesGameService/GetEncodedState",
|
||||
request_serializer=footsies__service__pb2.Empty.SerializeToString,
|
||||
response_deserializer=footsies__service__pb2.EncodedGameState.FromString,
|
||||
)
|
||||
self.IsReady = channel.unary_unary(
|
||||
"/FootsiesGameService/IsReady",
|
||||
request_serializer=footsies__service__pb2.Empty.SerializeToString,
|
||||
response_deserializer=footsies__service__pb2.BoolValue.FromString,
|
||||
)
|
||||
|
||||
|
||||
class FootsiesGameServiceServicer(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def StartGame(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def ResetGame(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def StepNFrames(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def GetState(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def GetEncodedState(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def IsReady(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
|
||||
def add_FootsiesGameServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
"StartGame": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.StartGame,
|
||||
request_deserializer=footsies__service__pb2.Empty.FromString,
|
||||
response_serializer=footsies__service__pb2.Empty.SerializeToString,
|
||||
),
|
||||
"ResetGame": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ResetGame,
|
||||
request_deserializer=footsies__service__pb2.Empty.FromString,
|
||||
response_serializer=footsies__service__pb2.Empty.SerializeToString,
|
||||
),
|
||||
"StepNFrames": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.StepNFrames,
|
||||
request_deserializer=footsies__service__pb2.StepInput.FromString,
|
||||
response_serializer=footsies__service__pb2.GameState.SerializeToString,
|
||||
),
|
||||
"GetState": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetState,
|
||||
request_deserializer=footsies__service__pb2.Empty.FromString,
|
||||
response_serializer=footsies__service__pb2.GameState.SerializeToString,
|
||||
),
|
||||
"GetEncodedState": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetEncodedState,
|
||||
request_deserializer=footsies__service__pb2.Empty.FromString,
|
||||
response_serializer=footsies__service__pb2.EncodedGameState.SerializeToString,
|
||||
),
|
||||
"IsReady": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.IsReady,
|
||||
request_deserializer=footsies__service__pb2.Empty.FromString,
|
||||
response_serializer=footsies__service__pb2.BoolValue.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
"FootsiesGameService", rpc_method_handlers
|
||||
)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class FootsiesGameService(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
@staticmethod
|
||||
def StartGame(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/FootsiesGameService/StartGame",
|
||||
footsies__service__pb2.Empty.SerializeToString,
|
||||
footsies__service__pb2.Empty.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ResetGame(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/FootsiesGameService/ResetGame",
|
||||
footsies__service__pb2.Empty.SerializeToString,
|
||||
footsies__service__pb2.Empty.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def StepNFrames(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/FootsiesGameService/StepNFrames",
|
||||
footsies__service__pb2.StepInput.SerializeToString,
|
||||
footsies__service__pb2.GameState.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def GetState(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/FootsiesGameService/GetState",
|
||||
footsies__service__pb2.Empty.SerializeToString,
|
||||
footsies__service__pb2.GameState.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def GetEncodedState(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/FootsiesGameService/GetEncodedState",
|
||||
footsies__service__pb2.Empty.SerializeToString,
|
||||
footsies__service__pb2.EncodedGameState.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def IsReady(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/FootsiesGameService/IsReady",
|
||||
footsies__service__pb2.Empty.SerializeToString,
|
||||
footsies__service__pb2.BoolValue.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import os
|
||||
import platform
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from ray.rllib.env import EnvContext
|
||||
from ray.rllib.examples.envs.classes.multi_agent.footsies.footsies_env import (
|
||||
env_creator,
|
||||
)
|
||||
|
||||
# Detect platform and choose appropriate binary
|
||||
if platform.system() == "Darwin":
|
||||
binary_to_download = "mac_headless"
|
||||
elif platform.system() == "Linux":
|
||||
binary_to_download = "linux_server"
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {platform.system()}")
|
||||
|
||||
FOOTSIES_ENV_BASE_CONFIG = {
|
||||
"max_t": 1000,
|
||||
"frame_skip": 4,
|
||||
"observation_delay": 16,
|
||||
"host": "localhost",
|
||||
"binary_download_dir": "/tmp/ray/binaries/footsies",
|
||||
"binary_extract_dir": "/tmp/ray/binaries/footsies",
|
||||
"binary_to_download": binary_to_download,
|
||||
}
|
||||
|
||||
_port_counter = 45001
|
||||
|
||||
|
||||
def _create_env(config_overrides):
|
||||
global _port_counter
|
||||
|
||||
config = {
|
||||
**FOOTSIES_ENV_BASE_CONFIG,
|
||||
"train_start_port": _port_counter,
|
||||
"eval_start_port": _port_counter + 1,
|
||||
**config_overrides,
|
||||
}
|
||||
_port_counter += 2
|
||||
|
||||
return env_creator(EnvContext(config, worker_index=0))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def capture_stdout_stderr():
|
||||
with tempfile.NamedTemporaryFile(mode="w+", delete=False) as tmp:
|
||||
log_path = tmp.name
|
||||
|
||||
# Use file descriptors 1 and 2 directly (stdout and stderr)
|
||||
# This bypasses pytest's wrapping of sys.stdout/stderr
|
||||
stdout_fd = 1
|
||||
stderr_fd = 2
|
||||
|
||||
# Save original file descriptors
|
||||
saved_stdout = os.dup(stdout_fd)
|
||||
saved_stderr = os.dup(stderr_fd)
|
||||
|
||||
log_file = None
|
||||
try:
|
||||
# Open log file with line buffering
|
||||
log_file = open(log_path, "w", buffering=1)
|
||||
os.dup2(log_file.fileno(), stdout_fd)
|
||||
os.dup2(log_file.fileno(), stderr_fd)
|
||||
|
||||
yield log_path
|
||||
|
||||
finally:
|
||||
# Flush the log file
|
||||
if log_file:
|
||||
log_file.flush()
|
||||
|
||||
# Restore original file descriptors
|
||||
os.dup2(saved_stdout, stdout_fd)
|
||||
os.dup2(saved_stderr, stderr_fd)
|
||||
|
||||
# Close file and saved descriptors
|
||||
if log_file:
|
||||
log_file.close()
|
||||
os.close(saved_stdout)
|
||||
os.close(saved_stderr)
|
||||
|
||||
|
||||
class TestFootsies(unittest.TestCase):
|
||||
def test_default_supress_output_mode(self):
|
||||
with capture_stdout_stderr() as log_path:
|
||||
env = _create_env({})
|
||||
time.sleep(2) # Give Unity time to write output
|
||||
env.close()
|
||||
# Give a bit more time for any buffered output to be written
|
||||
time.sleep(0.5)
|
||||
|
||||
# Read the captured output
|
||||
with open(log_path, "r") as f:
|
||||
captured_output = f.read()
|
||||
|
||||
assert (
|
||||
"`log_unity_output` not set in environment config, not logging output by default"
|
||||
in captured_output
|
||||
)
|
||||
assert "[UnityMemory]" not in captured_output
|
||||
|
||||
# Clean up
|
||||
if Path(log_path).exists():
|
||||
os.unlink(log_path)
|
||||
|
||||
def test_enable_output_mode(self):
|
||||
with capture_stdout_stderr() as log_path:
|
||||
env = _create_env({"log_unity_output": True})
|
||||
time.sleep(2) # Give Unity time to write output
|
||||
env.close()
|
||||
# Give a bit more time for any buffered output to be written
|
||||
time.sleep(0.5)
|
||||
|
||||
# Read the captured output
|
||||
with open(log_path, "r") as f:
|
||||
captured_output = f.read()
|
||||
|
||||
assert "[UnityMemory]" in captured_output
|
||||
|
||||
# Clean up
|
||||
if Path(log_path).exists():
|
||||
os.unlink(log_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,383 @@
|
||||
import collections
|
||||
import logging
|
||||
import platform
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
|
||||
import gymnasium as gym
|
||||
import numpy as np
|
||||
|
||||
from ray.rllib.algorithms.algorithm import Algorithm
|
||||
from ray.rllib.algorithms.callbacks import RLlibCallback
|
||||
from ray.rllib.core.rl_module import RLModuleSpec
|
||||
from ray.rllib.env.env_runner import EnvRunner
|
||||
from ray.rllib.env.multi_agent_episode import MultiAgentEpisode
|
||||
from ray.rllib.examples.envs.classes.multi_agent.footsies.game.constants import (
|
||||
FOOTSIES_ACTION_IDS,
|
||||
)
|
||||
from ray.rllib.utils.metrics import ENV_RUNNER_RESULTS
|
||||
from ray.rllib.utils.metrics.metrics_logger import MetricsLogger
|
||||
from ray.rllib.utils.typing import EpisodeType
|
||||
|
||||
logger = logging.getLogger("ray.rllib")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Matchup:
|
||||
p1: str
|
||||
p2: str
|
||||
prob: float
|
||||
|
||||
|
||||
class Matchmaker:
|
||||
def __init__(self, matchups: list[Matchup]):
|
||||
self.matchups = matchups
|
||||
self.probs = [matchup.prob for matchup in matchups]
|
||||
self.current_matchups = collections.defaultdict(dict)
|
||||
|
||||
def agent_to_module_mapping_fn(
|
||||
self, agent_id: str, episode: EpisodeType, **kwargs
|
||||
) -> str:
|
||||
"""Mapping function that retrieves policy_id from the sampled matchup"""
|
||||
id_ = episode.id_
|
||||
if self.current_matchups.get(id_) is None:
|
||||
# step 1: sample a matchup according to the specified probabilities
|
||||
sampled_matchup = np.random.choice(a=self.matchups, p=self.probs)
|
||||
|
||||
# step 2: Randomize who is player 1 and player 2
|
||||
policies = [sampled_matchup.p1, sampled_matchup.p2]
|
||||
p1, p2 = np.random.choice(policies, size=2, replace=False)
|
||||
|
||||
# step 3: Set as the current matchup for the episode in question (id_)
|
||||
self.current_matchups[id_]["p1"] = p1
|
||||
self.current_matchups[id_]["p2"] = p2
|
||||
|
||||
policy_id = self.current_matchups[id_].pop(agent_id)
|
||||
|
||||
# remove (an empty dict) for the current episode with id_
|
||||
if not self.current_matchups[id_]:
|
||||
del self.current_matchups[id_]
|
||||
|
||||
return policy_id
|
||||
|
||||
|
||||
class MetricsLoggerCallback(RLlibCallback):
|
||||
def __init__(self, main_policy: str) -> None:
|
||||
"""Log experiment metrics
|
||||
|
||||
Logs metrics after each episode step and at the end of each (train or eval) episode.
|
||||
Metrics logged at the end of each episode will be later used by MixManagerCallback
|
||||
to decide whether to add a new opponent to the mix.
|
||||
"""
|
||||
super().__init__()
|
||||
self.main_policy = main_policy
|
||||
self.action_id_to_str = {
|
||||
action_id: action_str
|
||||
for action_str, action_id in FOOTSIES_ACTION_IDS.items()
|
||||
}
|
||||
|
||||
def on_episode_step(
|
||||
self,
|
||||
*,
|
||||
episode: MultiAgentEpisode,
|
||||
env_runner: Optional[EnvRunner] = None,
|
||||
metrics_logger: Optional[MetricsLogger] = None,
|
||||
env: Optional[gym.Env] = None,
|
||||
env_index: int,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Log action usage frequency
|
||||
|
||||
Log actions performed by both players at each step of the (training or evaluation) episode.
|
||||
"""
|
||||
stage = "eval" if env_runner.config.in_evaluation else "train"
|
||||
|
||||
# get the ModuleID for each agent
|
||||
p1_module = episode.module_for("p1")
|
||||
p2_module = episode.module_for("p2")
|
||||
|
||||
# get action string for each agent
|
||||
p1_action_id = env.envs[
|
||||
env_index
|
||||
].unwrapped.last_game_state.player1.current_action_id
|
||||
p2_action_id = env.envs[
|
||||
env_index
|
||||
].unwrapped.last_game_state.player2.current_action_id
|
||||
p1_action_str = self.action_id_to_str[p1_action_id]
|
||||
p2_action_str = self.action_id_to_str[p2_action_id]
|
||||
|
||||
metrics_logger.log_value(
|
||||
key=f"footsies/{stage}/actions/{p1_module}/{p1_action_str}",
|
||||
value=1,
|
||||
reduce="sum",
|
||||
window=100,
|
||||
)
|
||||
metrics_logger.log_value(
|
||||
key=f"footsies/{stage}/actions/{p2_module}/{p2_action_str}",
|
||||
value=1,
|
||||
reduce="sum",
|
||||
window=100,
|
||||
)
|
||||
|
||||
def on_episode_end(
|
||||
self,
|
||||
*,
|
||||
episode: MultiAgentEpisode,
|
||||
env_runner: Optional[EnvRunner] = None,
|
||||
metrics_logger: Optional[MetricsLogger] = None,
|
||||
env: Optional[gym.Env] = None,
|
||||
env_index: int,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Log win rates
|
||||
|
||||
Log win rates of the main policy against its opponent at the end of the (training or evaluation) episode.
|
||||
"""
|
||||
stage = "eval" if env_runner.config.in_evaluation else "train"
|
||||
|
||||
# check status of "p1" and "p2"
|
||||
last_game_state = env.envs[env_index].unwrapped.last_game_state
|
||||
p1_dead = last_game_state.player1.is_dead
|
||||
p2_dead = last_game_state.player2.is_dead
|
||||
|
||||
# get the ModuleID for each agent
|
||||
p1_module = episode.module_for("p1")
|
||||
p2_module = episode.module_for("p2")
|
||||
|
||||
if self.main_policy == p1_module:
|
||||
opponent_id = p2_module
|
||||
main_policy_win = p2_dead
|
||||
elif self.main_policy == p2_module:
|
||||
opponent_id = p1_module
|
||||
main_policy_win = p1_dead
|
||||
else:
|
||||
logger.info(
|
||||
f"RLlib {self.__class__.__name__}: Main policy: '{self.main_policy}' not found in this episode. "
|
||||
f"Policies in this episode are: '{p1_module}' and '{p2_module}'. "
|
||||
f"Check your multi_agent 'policy_mapping_fn'. "
|
||||
f"Metrics logging for this episode will be skipped."
|
||||
)
|
||||
return
|
||||
|
||||
if p1_dead and p2_dead:
|
||||
metrics_logger.log_value(
|
||||
key=f"footsies/{stage}/both_dead/{self.main_policy}/vs_{opponent_id}",
|
||||
value=1,
|
||||
reduce="mean",
|
||||
window=100,
|
||||
)
|
||||
elif not p1_dead and not p2_dead:
|
||||
metrics_logger.log_value(
|
||||
key=f"footsies/{stage}/both_alive/{self.main_policy}/vs_{opponent_id}",
|
||||
value=1,
|
||||
reduce="mean",
|
||||
window=100,
|
||||
)
|
||||
else:
|
||||
# log the win rate against the opponent with an 'opponent_id'
|
||||
metrics_logger.log_value(
|
||||
key=f"footsies/{stage}/win_rates/{self.main_policy}/vs_{opponent_id}",
|
||||
value=int(main_policy_win),
|
||||
reduce="mean",
|
||||
window=100,
|
||||
)
|
||||
|
||||
# log the win rate, without specifying the opponent
|
||||
# this metric collected from the eval env runner
|
||||
# will be used to decide whether to add
|
||||
# a new opponent at the current level.
|
||||
metrics_logger.log_value(
|
||||
key=f"footsies/{stage}/win_rates/{self.main_policy}/vs_any",
|
||||
value=int(main_policy_win),
|
||||
reduce="mean",
|
||||
window=100,
|
||||
)
|
||||
|
||||
|
||||
class MixManagerCallback(RLlibCallback):
|
||||
def __init__(
|
||||
self,
|
||||
win_rate_threshold: float,
|
||||
main_policy: str,
|
||||
target_mix_size: int,
|
||||
starting_modules=list[str], # default is ["lstm", "noop"]
|
||||
fixed_modules_progression_sequence=tuple[str], # default is ("noop", "back")
|
||||
) -> None:
|
||||
"""Track win rates and manage mix of opponents"""
|
||||
super().__init__()
|
||||
self.win_rate_threshold = win_rate_threshold
|
||||
self.main_policy = main_policy
|
||||
self.target_mix_size = target_mix_size
|
||||
self.fixed_modules_progression_sequence = tuple(
|
||||
fixed_modules_progression_sequence
|
||||
) # Order of RL modules to be added to the mix
|
||||
self.modules_in_mix = list(
|
||||
starting_modules
|
||||
) # RLModules that are currently in the mix
|
||||
self._trained_policy_idx = (
|
||||
0 # We will use this to create new opponents of the main policy
|
||||
)
|
||||
|
||||
def on_evaluate_end(
|
||||
self,
|
||||
*,
|
||||
algorithm: Algorithm,
|
||||
metrics_logger: Optional[MetricsLogger] = None,
|
||||
evaluation_metrics: dict,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Check win rates and add new opponent if necessary.
|
||||
|
||||
Check the win rate of the main policy against its current opponent.
|
||||
If the win rate exceeds the specified threshold, add a new opponent to the mix, by modifying:
|
||||
1. update the policy_mapping_fn for (training and evaluation) env runners
|
||||
2. if the new policy is a trained one (not a fixed RL module), modify Algorithm's state (initialize the state of the newly added RLModule by using the main policy)
|
||||
"""
|
||||
_main_module = algorithm.get_module(self.main_policy)
|
||||
new_module_id = None
|
||||
new_module_spec = None
|
||||
|
||||
win_rate = evaluation_metrics[ENV_RUNNER_RESULTS][
|
||||
f"footsies/eval/win_rates/{self.main_policy}/vs_any"
|
||||
]
|
||||
|
||||
if win_rate > self.win_rate_threshold:
|
||||
logger.info(
|
||||
f"RLlib {self.__class__.__name__}: Win rate for main policy '{self.main_policy}' "
|
||||
f"exceeded threshold ({win_rate} > {self.win_rate_threshold})."
|
||||
f" Adding new RL Module to the mix..."
|
||||
)
|
||||
|
||||
# check if fixed RL module should be added to the mix,
|
||||
# and if so, create new_module_id and new_module_spec for it
|
||||
for module_id in self.fixed_modules_progression_sequence:
|
||||
if module_id not in self.modules_in_mix:
|
||||
new_module_id = module_id
|
||||
break
|
||||
|
||||
# in case that all fixed RL Modules are already in the mix (together with the main policy),
|
||||
# we will add a new RL Module by taking main policy and adding an instance of it to the mix
|
||||
if new_module_id is None:
|
||||
new_module_id = f"{self.main_policy}_v{self._trained_policy_idx}"
|
||||
new_module_spec = RLModuleSpec.from_module(_main_module)
|
||||
self._trained_policy_idx += 1
|
||||
|
||||
# create new policy mapping function, to ensure that the main policy plays against newly added policy
|
||||
new_mapping_fn = Matchmaker(
|
||||
[
|
||||
Matchup(
|
||||
p1=self.main_policy,
|
||||
p2=new_module_id,
|
||||
prob=1.0,
|
||||
)
|
||||
]
|
||||
).agent_to_module_mapping_fn
|
||||
|
||||
# STEP 1: Add the new module first (if it's a trained module)
|
||||
if new_module_id not in self.fixed_modules_progression_sequence:
|
||||
# Add module to Learners and EnvRunners (but don't update mapping yet)
|
||||
algorithm.add_module(
|
||||
module_id=new_module_id,
|
||||
module_spec=new_module_spec,
|
||||
new_agent_to_module_mapping_fn=None, # Don't update mapping yet!
|
||||
)
|
||||
|
||||
# Initialize the new module with main policy's weights
|
||||
algorithm.set_state(
|
||||
{
|
||||
"learner_group": {
|
||||
"learner": {
|
||||
"rl_module": {
|
||||
new_module_id: _main_module.get_state(),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# STEP 2: CRITICAL - Update aggregator actors with the new module
|
||||
# Aggregators run the learner connector pipeline which needs all modules.
|
||||
if (
|
||||
hasattr(algorithm, "_aggregator_actor_manager")
|
||||
and algorithm._aggregator_actor_manager
|
||||
):
|
||||
logger.info(
|
||||
f"RLlib {self.__class__.__name__}: Updating aggregator actors "
|
||||
f"with new module '{new_module_id}'..."
|
||||
)
|
||||
|
||||
# Add the new module to each aggregator actor's MultiRLModule
|
||||
algorithm._aggregator_actor_manager.foreach_actor(
|
||||
func=lambda actor, mid=new_module_id, spec=new_module_spec: (
|
||||
actor._module.add_module(
|
||||
module_id=mid,
|
||||
module=spec.build(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Sync weights from learner to aggregator actors
|
||||
weights = algorithm.learner_group.get_weights(
|
||||
module_ids=[new_module_id]
|
||||
)
|
||||
algorithm._aggregator_actor_manager.foreach_actor(
|
||||
func=lambda actor, w=weights: actor._module.set_state(w)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"RLlib {self.__class__.__name__}: Aggregator actors updated successfully."
|
||||
)
|
||||
|
||||
# STEP 3: NOW update the policy mapping function on all EnvRunners
|
||||
# At this point, the module exists everywhere (Learners, EnvRunners, Aggregators)
|
||||
algorithm.env_runner_group.foreach_env_runner(
|
||||
lambda er: er.config.multi_agent(policy_mapping_fn=new_mapping_fn),
|
||||
local_env_runner=True,
|
||||
)
|
||||
|
||||
algorithm.eval_env_runner_group.foreach_env_runner(
|
||||
lambda er: er.config.multi_agent(policy_mapping_fn=new_mapping_fn),
|
||||
local_env_runner=True,
|
||||
)
|
||||
# Update algorithm's config to maintain consistency
|
||||
algorithm.config._is_frozen = False
|
||||
algorithm.config.multi_agent(policy_mapping_fn=new_mapping_fn)
|
||||
algorithm.config.freeze()
|
||||
|
||||
# Update the current mix list
|
||||
self.modules_in_mix.append(new_module_id)
|
||||
|
||||
else:
|
||||
logger.info(
|
||||
f"RLlib {self.__class__.__name__}: Win rate for main policy '{self.main_policy}' "
|
||||
f"did not exceed threshold ({win_rate} <= {self.win_rate_threshold})."
|
||||
)
|
||||
|
||||
def on_train_result(
|
||||
self,
|
||||
*,
|
||||
algorithm: Algorithm,
|
||||
metrics_logger: Optional[MetricsLogger] = None,
|
||||
result: Dict,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Report the current mix size at the end of training iteration.
|
||||
|
||||
That will tell Ray Tune, whether to stop training (once the 'target_mix_size' has been reached).
|
||||
"""
|
||||
result["mix_size"] = len(self.modules_in_mix)
|
||||
|
||||
|
||||
def platform_for_binary_to_download(render: bool) -> str:
|
||||
if platform.system() == "Darwin":
|
||||
if render:
|
||||
return "mac_windowed"
|
||||
else:
|
||||
return "mac_headless"
|
||||
elif platform.system() == "Linux":
|
||||
if render:
|
||||
return "linux_windowed"
|
||||
else:
|
||||
return "linux_server"
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {platform.system()}")
|
||||
@@ -0,0 +1,89 @@
|
||||
import gymnasium as gym
|
||||
|
||||
from ray.rllib.env.multi_agent_env import MultiAgentEnv
|
||||
|
||||
|
||||
class GuessTheNumberGame(MultiAgentEnv):
|
||||
"""
|
||||
We have two players, 0 and 1. Agent 0 has to pick a number between 0, MAX-1
|
||||
at reset. Agent 1 has to guess the number by asking N questions of whether
|
||||
of the form of "a <number> is higher|lower|equal to the picked number. The
|
||||
action space is MultiDiscrete [3, MAX]. For the first index 0 means lower,
|
||||
1 means higher and 2 means equal. The environment answers with yes (1) or
|
||||
no (0) on the reward function. Every time step that agent 1 wastes agent 0
|
||||
gets a reward of 1. After N steps the game is terminated. If agent 1
|
||||
guesses the number correctly, it gets a reward of 100 points, otherwise it
|
||||
gets a reward of 0. On the other hand if agent 0 wins they win 100 points.
|
||||
The optimal policy controlling agent 1 should converge to a binary search
|
||||
strategy.
|
||||
"""
|
||||
|
||||
MAX_NUMBER = 3
|
||||
MAX_STEPS = 20
|
||||
|
||||
def __init__(self, config=None):
|
||||
super().__init__()
|
||||
self._agent_ids = {0, 1}
|
||||
|
||||
self.max_number = config.get("max_number", self.MAX_NUMBER)
|
||||
self.max_steps = config.get("max_steps", self.MAX_STEPS)
|
||||
|
||||
self._number = None
|
||||
self.observation_space = gym.spaces.Discrete(2)
|
||||
self.action_space = gym.spaces.MultiDiscrete([3, self.max_number])
|
||||
|
||||
def reset(self, *, seed=None, options=None):
|
||||
self._step = 0
|
||||
self._number = None
|
||||
# agent 0 has to pick a number. So the returned obs does not matter.
|
||||
return {0: 0}, {}
|
||||
|
||||
def step(self, action_dict):
|
||||
# get agent 0's action
|
||||
agent_0_action = action_dict.get(0)
|
||||
|
||||
if agent_0_action is not None:
|
||||
# ignore the first part of the action and look at the number
|
||||
self._number = agent_0_action[1]
|
||||
# next obs should tell agent 1 to start guessing.
|
||||
# the returned reward and dones should be on agent 0 who picked a
|
||||
# number.
|
||||
return (
|
||||
{1: 0},
|
||||
{0: 0},
|
||||
{0: False, "__all__": False},
|
||||
{0: False, "__all__": False},
|
||||
{},
|
||||
)
|
||||
|
||||
if self._number is None:
|
||||
raise ValueError(
|
||||
"No number is selected by agent 0. Have you restarted "
|
||||
"the environment?"
|
||||
)
|
||||
|
||||
# get agent 1's action
|
||||
direction, number = action_dict.get(1)
|
||||
info = {}
|
||||
# always the same, we don't need agent 0 to act ever again, agent 1 should keep
|
||||
# guessing.
|
||||
obs = {1: 0}
|
||||
guessed_correctly = False
|
||||
terminated = {1: False, "__all__": False}
|
||||
truncated = {1: False, "__all__": False}
|
||||
# everytime agent 1 does not guess correctly agent 0 gets a reward of 1.
|
||||
if direction == 0: # lower
|
||||
reward = {1: int(number > self._number), 0: 1}
|
||||
elif direction == 1: # higher
|
||||
reward = {1: int(number < self._number), 0: 1}
|
||||
else: # equal
|
||||
guessed_correctly = number == self._number
|
||||
reward = {1: guessed_correctly * 100, 0: guessed_correctly * -100}
|
||||
terminated = {1: guessed_correctly, "__all__": guessed_correctly}
|
||||
|
||||
self._step += 1
|
||||
if self._step >= self.max_steps: # max number of steps episode is over
|
||||
truncated["__all__"] = True
|
||||
if not guessed_correctly:
|
||||
reward[0] = 100 # agent 0 wins
|
||||
return obs, reward, terminated, truncated, info
|
||||
@@ -0,0 +1,236 @@
|
||||
import copy
|
||||
from typing import Any, Dict
|
||||
|
||||
import chess as ch
|
||||
import numpy as np
|
||||
from pettingzoo import AECEnv
|
||||
from pettingzoo.classic.chess.chess import raw_env as chess_v5
|
||||
|
||||
from ray.rllib.env.multi_agent_env import MultiAgentEnv
|
||||
|
||||
|
||||
class MultiAgentChess(MultiAgentEnv):
|
||||
"""An interface to the PettingZoo MARL environment library.
|
||||
See: https://github.com/Farama-Foundation/PettingZoo
|
||||
Inherits from MultiAgentEnv and exposes a given AEC
|
||||
(actor-environment-cycle) game from the PettingZoo project via the
|
||||
MultiAgentEnv public API.
|
||||
Note that the wrapper has some important limitations:
|
||||
1. All agents have the same action_spaces and observation_spaces.
|
||||
Note: If, within your aec game, agents do not have homogeneous action /
|
||||
observation spaces, apply SuperSuit wrappers
|
||||
to apply padding functionality: https://github.com/Farama-Foundation/
|
||||
SuperSuit#built-in-multi-agent-only-functions
|
||||
2. Environments are positive sum games (-> Agents are expected to cooperate
|
||||
to maximize reward). This isn't a hard restriction, it just that
|
||||
standard algorithms aren't expected to work well in highly competitive
|
||||
games.
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
from pettingzoo.butterfly import prison_v3
|
||||
from ray.rllib.env.wrappers.pettingzoo_env import PettingZooEnv
|
||||
env = PettingZooEnv(prison_v3.env())
|
||||
obs = env.reset()
|
||||
print(obs)
|
||||
# only returns the observation for the agent which should be stepping
|
||||
|
||||
.. testoutput::
|
||||
|
||||
{
|
||||
'prisoner_0': array([[[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
...,
|
||||
[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 0]]], dtype=uint8)
|
||||
}
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
obs, rewards, dones, infos = env.step({
|
||||
"prisoner_0": 1
|
||||
})
|
||||
# only returns the observation, reward, info, etc, for
|
||||
# the agent who's turn is next.
|
||||
print(obs)
|
||||
|
||||
.. testoutput::
|
||||
|
||||
{
|
||||
'prisoner_1': array([[[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
...,
|
||||
[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 0]]], dtype=uint8)
|
||||
}
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
print(rewards)
|
||||
|
||||
.. testoutput::
|
||||
|
||||
{
|
||||
'prisoner_1': 0
|
||||
}
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
print(dones)
|
||||
|
||||
.. testoutput::
|
||||
|
||||
{
|
||||
'prisoner_1': False, '__all__': False
|
||||
}
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
print(infos)
|
||||
|
||||
.. testoutput::
|
||||
|
||||
{
|
||||
'prisoner_1': {'map_tuple': (1, 0)}
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[Any, Any] = None,
|
||||
env: AECEnv = None,
|
||||
):
|
||||
super().__init__()
|
||||
if env is None:
|
||||
self.env = chess_v5()
|
||||
else:
|
||||
self.env = env
|
||||
self.env.reset()
|
||||
|
||||
self.config = config
|
||||
if self.config is None:
|
||||
self.config = {}
|
||||
try:
|
||||
self.config["random_start"] = self.config["random_start"]
|
||||
except KeyError:
|
||||
self.config["random_start"] = 4
|
||||
|
||||
# If these important attributes are not set, try to infer them.
|
||||
if not self.agents:
|
||||
self.agents = list(self._agent_ids)
|
||||
if not self.possible_agents:
|
||||
self.possible_agents = self.agents.copy()
|
||||
|
||||
# Get first observation space, assuming all agents have equal space
|
||||
self.observation_space = self.env.observation_space(self.env.agents[0])
|
||||
|
||||
# Get first action space, assuming all agents have equal space
|
||||
self.action_space = self.env.action_space(self.env.agents[0])
|
||||
|
||||
assert all(
|
||||
self.env.observation_space(agent) == self.observation_space
|
||||
for agent in self.env.agents
|
||||
), (
|
||||
"Observation spaces for all agents must be identical. Perhaps "
|
||||
"SuperSuit's pad_observations wrapper can help (useage: "
|
||||
"`supersuit.aec_wrappers.pad_observations(env)`"
|
||||
)
|
||||
|
||||
assert all(
|
||||
self.env.action_space(agent) == self.action_space
|
||||
for agent in self.env.agents
|
||||
), (
|
||||
"Action spaces for all agents must be identical. Perhaps "
|
||||
"SuperSuit's pad_action_space wrapper can help (usage: "
|
||||
"`supersuit.aec_wrappers.pad_action_space(env)`"
|
||||
)
|
||||
self._agent_ids = set(self.env.agents)
|
||||
|
||||
def random_start(self, random_moves):
|
||||
self.env.board = ch.Board()
|
||||
for i in range(random_moves):
|
||||
self.env.board.push(np.random.choice(list(self.env.board.legal_moves)))
|
||||
return self.env.board
|
||||
|
||||
def observe(self):
|
||||
return {
|
||||
self.env.agent_selection: self.env.observe(self.env.agent_selection),
|
||||
"state": self.get_state(),
|
||||
}
|
||||
|
||||
def reset(self, *args, **kwargs):
|
||||
self.env.reset()
|
||||
if self.config["random_start"] > 0:
|
||||
self.random_start(self.config["random_start"])
|
||||
return (
|
||||
{self.env.agent_selection: self.env.observe(self.env.agent_selection)},
|
||||
{self.env.agent_selection: {}},
|
||||
)
|
||||
|
||||
def step(self, action):
|
||||
try:
|
||||
self.env.step(action[self.env.agent_selection])
|
||||
except (KeyError, IndexError):
|
||||
self.env.step(action)
|
||||
except AssertionError:
|
||||
# Illegal action
|
||||
print(action)
|
||||
raise AssertionError("Illegal action")
|
||||
|
||||
obs_d = {}
|
||||
rew_d = {}
|
||||
done_d = {}
|
||||
truncated_d = {}
|
||||
info_d = {}
|
||||
while self.env.agents:
|
||||
obs, rew, done, trunc, info = self.env.last()
|
||||
a = self.env.agent_selection
|
||||
obs_d[a] = obs
|
||||
rew_d[a] = rew
|
||||
done_d[a] = done
|
||||
truncated_d[a] = trunc
|
||||
info_d[a] = info
|
||||
if self.env.terminations[self.env.agent_selection]:
|
||||
self.env.step(None)
|
||||
done_d["__all__"] = True
|
||||
truncated_d["__all__"] = True
|
||||
else:
|
||||
done_d["__all__"] = False
|
||||
truncated_d["__all__"] = False
|
||||
break
|
||||
|
||||
return obs_d, rew_d, done_d, truncated_d, info_d
|
||||
|
||||
def close(self):
|
||||
self.env.close()
|
||||
|
||||
def seed(self, seed=None):
|
||||
self.env.seed(seed)
|
||||
|
||||
def render(self, mode="human"):
|
||||
return self.env.render(mode)
|
||||
|
||||
@property
|
||||
def agent_selection(self):
|
||||
return self.env.agent_selection
|
||||
|
||||
@property
|
||||
def get_sub_environments(self):
|
||||
return self.env.unwrapped
|
||||
|
||||
def get_state(self):
|
||||
state = copy.deepcopy(self.env)
|
||||
return state
|
||||
|
||||
def set_state(self, state):
|
||||
self.env = copy.deepcopy(state)
|
||||
return self.env.observe(self.env.agent_selection)
|
||||
@@ -0,0 +1,220 @@
|
||||
import copy
|
||||
from typing import Any, Dict
|
||||
|
||||
from pettingzoo import AECEnv
|
||||
from pettingzoo.classic.connect_four_v3 import raw_env as connect_four_v3
|
||||
|
||||
from ray.rllib.env.multi_agent_env import MultiAgentEnv
|
||||
|
||||
|
||||
class MultiAgentConnect4(MultiAgentEnv):
|
||||
"""An interface to the PettingZoo MARL environment library.
|
||||
See: https://github.com/Farama-Foundation/PettingZoo
|
||||
Inherits from MultiAgentEnv and exposes a given AEC
|
||||
(actor-environment-cycle) game from the PettingZoo project via the
|
||||
MultiAgentEnv public API.
|
||||
Note that the wrapper has some important limitations:
|
||||
1. All agents have the same action_spaces and observation_spaces.
|
||||
Note: If, within your aec game, agents do not have homogeneous action /
|
||||
observation spaces, apply SuperSuit wrappers
|
||||
to apply padding functionality: https://github.com/Farama-Foundation/
|
||||
SuperSuit#built-in-multi-agent-only-functions
|
||||
2. Environments are positive sum games (-> Agents are expected to cooperate
|
||||
to maximize reward). This isn't a hard restriction, it just that
|
||||
standard algorithms aren't expected to work well in highly competitive
|
||||
games.
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
from pettingzoo.butterfly import prison_v3
|
||||
from ray.rllib.env.wrappers.pettingzoo_env import PettingZooEnv
|
||||
env = PettingZooEnv(prison_v3.env())
|
||||
obs = env.reset()
|
||||
print(obs)
|
||||
|
||||
.. testoutput::
|
||||
|
||||
# only returns the observation for the agent which should be stepping
|
||||
{
|
||||
'prisoner_0': array([[[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
...,
|
||||
[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 0]]], dtype=uint8)
|
||||
}
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
obs, rewards, dones, infos = env.step({
|
||||
"prisoner_0": 1
|
||||
})
|
||||
# only returns the observation, reward, info, etc, for
|
||||
# the agent who's turn is next.
|
||||
print(obs)
|
||||
|
||||
.. testoutput::
|
||||
|
||||
{
|
||||
'prisoner_1': array([[[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
...,
|
||||
[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 0]]], dtype=uint8)
|
||||
}
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
print(rewards)
|
||||
|
||||
.. testoutput::
|
||||
|
||||
{
|
||||
'prisoner_1': 0
|
||||
}
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
print(dones)
|
||||
|
||||
.. testoutput::
|
||||
|
||||
{
|
||||
'prisoner_1': False, '__all__': False
|
||||
}
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
print(infos)
|
||||
|
||||
.. testoutput::
|
||||
|
||||
{
|
||||
'prisoner_1': {'map_tuple': (1, 0)}
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[Any, Any] = None,
|
||||
env: AECEnv = None,
|
||||
):
|
||||
super().__init__()
|
||||
if env is None:
|
||||
self.env = connect_four_v3()
|
||||
else:
|
||||
self.env = env
|
||||
self.env.reset()
|
||||
|
||||
# If these important attributes are not set, try to infer them.
|
||||
if not self.agents:
|
||||
self.agents = list(self._agent_ids)
|
||||
if not self.possible_agents:
|
||||
self.possible_agents = self.agents.copy()
|
||||
|
||||
self.config = config
|
||||
# Get first observation space, assuming all agents have equal space
|
||||
self.observation_space = self.env.observation_space(self.env.agents[0])
|
||||
|
||||
# Get first action space, assuming all agents have equal space
|
||||
self.action_space = self.env.action_space(self.env.agents[0])
|
||||
|
||||
assert all(
|
||||
self.env.observation_space(agent) == self.observation_space
|
||||
for agent in self.env.agents
|
||||
), (
|
||||
"Observation spaces for all agents must be identical. Perhaps "
|
||||
"SuperSuit's pad_observations wrapper can help (useage: "
|
||||
"`supersuit.aec_wrappers.pad_observations(env)`"
|
||||
)
|
||||
|
||||
assert all(
|
||||
self.env.action_space(agent) == self.action_space
|
||||
for agent in self.env.agents
|
||||
), (
|
||||
"Action spaces for all agents must be identical. Perhaps "
|
||||
"SuperSuit's pad_action_space wrapper can help (usage: "
|
||||
"`supersuit.aec_wrappers.pad_action_space(env)`"
|
||||
)
|
||||
|
||||
self._agent_ids = set(self.env.agents)
|
||||
|
||||
def observe(self):
|
||||
return {
|
||||
self.env.agent_selection: self.env.observe(self.env.agent_selection),
|
||||
"state": self.get_state(),
|
||||
}
|
||||
|
||||
def reset(self, *args, **kwargs):
|
||||
self.env.reset()
|
||||
return (
|
||||
{self.env.agent_selection: self.env.observe(self.env.agent_selection)},
|
||||
{self.env.agent_selection: {}},
|
||||
)
|
||||
|
||||
def step(self, action):
|
||||
try:
|
||||
self.env.step(action[self.env.agent_selection])
|
||||
except (KeyError, IndexError):
|
||||
self.env.step(action)
|
||||
except AssertionError:
|
||||
# Illegal action
|
||||
print(action)
|
||||
raise AssertionError("Illegal action")
|
||||
|
||||
obs_d = {}
|
||||
rew_d = {}
|
||||
done_d = {}
|
||||
trunc_d = {}
|
||||
info_d = {}
|
||||
while self.env.agents:
|
||||
obs, rew, done, trunc, info = self.env.last()
|
||||
a = self.env.agent_selection
|
||||
obs_d[a] = obs
|
||||
rew_d[a] = rew
|
||||
done_d[a] = done
|
||||
trunc_d[a] = trunc
|
||||
info_d[a] = info
|
||||
if self.env.terminations[self.env.agent_selection]:
|
||||
self.env.step(None)
|
||||
done_d["__all__"] = True
|
||||
trunc_d["__all__"] = True
|
||||
else:
|
||||
done_d["__all__"] = False
|
||||
trunc_d["__all__"] = False
|
||||
break
|
||||
|
||||
return obs_d, rew_d, done_d, trunc_d, info_d
|
||||
|
||||
def close(self):
|
||||
self.env.close()
|
||||
|
||||
def seed(self, seed=None):
|
||||
self.env.seed(seed)
|
||||
|
||||
def render(self, mode="human"):
|
||||
return self.env.render(mode)
|
||||
|
||||
@property
|
||||
def agent_selection(self):
|
||||
return self.env.agent_selection
|
||||
|
||||
@property
|
||||
def get_sub_environments(self):
|
||||
return self.env.unwrapped
|
||||
|
||||
def get_state(self):
|
||||
state = copy.deepcopy(self.env)
|
||||
return state
|
||||
|
||||
def set_state(self, state):
|
||||
self.env = copy.deepcopy(state)
|
||||
return self.env.observe(self.env.agent_selection)
|
||||
@@ -0,0 +1,125 @@
|
||||
# __sphinx_doc_1_begin__
|
||||
import gymnasium as gym
|
||||
|
||||
from ray.rllib.env.multi_agent_env import MultiAgentEnv
|
||||
|
||||
|
||||
class RockPaperScissors(MultiAgentEnv):
|
||||
"""Two-player environment for the famous rock paper scissors game.
|
||||
|
||||
# __sphinx_doc_1_end__
|
||||
Optionally, the "Sheldon Cooper extension" can be activated by passing
|
||||
`sheldon_cooper_mode=True` into the constructor, in which case two more moves
|
||||
are allowed: Spock and Lizard. Spock is poisoned by Lizard, disproven by Paper, but
|
||||
crushes Rock and smashes Scissors. Lizard poisons Spock and eats Paper, but is
|
||||
decapitated by Scissors and crushed by Rock.
|
||||
|
||||
# __sphinx_doc_2_begin__
|
||||
Both players always move simultaneously over a course of 10 timesteps in total.
|
||||
The winner of each timestep receives reward of +1, the losing player -1.0.
|
||||
|
||||
The observation of each player is the last opponent action.
|
||||
"""
|
||||
|
||||
ROCK = 0
|
||||
PAPER = 1
|
||||
SCISSORS = 2
|
||||
LIZARD = 3
|
||||
SPOCK = 4
|
||||
|
||||
WIN_MATRIX = {
|
||||
(ROCK, ROCK): (0, 0),
|
||||
(ROCK, PAPER): (-1, 1),
|
||||
(ROCK, SCISSORS): (1, -1),
|
||||
(PAPER, ROCK): (1, -1),
|
||||
(PAPER, PAPER): (0, 0),
|
||||
(PAPER, SCISSORS): (-1, 1),
|
||||
(SCISSORS, ROCK): (-1, 1),
|
||||
(SCISSORS, PAPER): (1, -1),
|
||||
(SCISSORS, SCISSORS): (0, 0),
|
||||
}
|
||||
# __sphinx_doc_2_end__
|
||||
|
||||
WIN_MATRIX.update(
|
||||
{
|
||||
# Sheldon Cooper mode:
|
||||
(LIZARD, LIZARD): (0, 0),
|
||||
(LIZARD, SPOCK): (1, -1), # Lizard poisons Spock
|
||||
(LIZARD, ROCK): (-1, 1), # Rock crushes lizard
|
||||
(LIZARD, PAPER): (1, -1), # Lizard eats paper
|
||||
(LIZARD, SCISSORS): (-1, 1), # Scissors decapitate lizard
|
||||
(ROCK, LIZARD): (1, -1), # Rock crushes lizard
|
||||
(PAPER, LIZARD): (-1, 1), # Lizard eats paper
|
||||
(SCISSORS, LIZARD): (1, -1), # Scissors decapitate lizard
|
||||
(SPOCK, SPOCK): (0, 0),
|
||||
(SPOCK, LIZARD): (-1, 1), # Lizard poisons Spock
|
||||
(SPOCK, ROCK): (1, -1), # Spock vaporizes rock
|
||||
(SPOCK, PAPER): (-1, 1), # Paper disproves Spock
|
||||
(SPOCK, SCISSORS): (1, -1), # Spock smashes scissors
|
||||
(ROCK, SPOCK): (-1, 1), # Spock vaporizes rock
|
||||
(PAPER, SPOCK): (1, -1), # Paper disproves Spock
|
||||
(SCISSORS, SPOCK): (-1, 1), # Spock smashes scissors
|
||||
}
|
||||
)
|
||||
|
||||
# __sphinx_doc_3_begin__
|
||||
def __init__(self, config=None):
|
||||
super().__init__()
|
||||
|
||||
self.agents = self.possible_agents = ["player1", "player2"]
|
||||
|
||||
# The observations are always the last taken actions. Hence observation- and
|
||||
# action spaces are identical.
|
||||
self.observation_spaces = self.action_spaces = {
|
||||
"player1": gym.spaces.Discrete(3),
|
||||
"player2": gym.spaces.Discrete(3),
|
||||
}
|
||||
self.last_move = None
|
||||
self.num_moves = 0
|
||||
# __sphinx_doc_3_end__
|
||||
|
||||
self.sheldon_cooper_mode = False
|
||||
if config.get("sheldon_cooper_mode"):
|
||||
self.sheldon_cooper_mode = True
|
||||
self.action_spaces = self.observation_spaces = {
|
||||
"player1": gym.spaces.Discrete(5),
|
||||
"player2": gym.spaces.Discrete(5),
|
||||
}
|
||||
|
||||
# __sphinx_doc_4_begin__
|
||||
def reset(self, *, seed=None, options=None):
|
||||
self.num_moves = 0
|
||||
|
||||
# The first observation should not matter (none of the agents has moved yet).
|
||||
# Set them to 0.
|
||||
return {
|
||||
"player1": 0,
|
||||
"player2": 0,
|
||||
}, {} # <- empty infos dict
|
||||
|
||||
# __sphinx_doc_4_end__
|
||||
|
||||
# __sphinx_doc_5_begin__
|
||||
def step(self, action_dict):
|
||||
self.num_moves += 1
|
||||
|
||||
move1 = action_dict["player1"]
|
||||
move2 = action_dict["player2"]
|
||||
|
||||
# Set the next observations (simply use the other player's action).
|
||||
# Note that because we are publishing both players in the observations dict,
|
||||
# we expect both players to act in the next `step()` (simultaneous stepping).
|
||||
observations = {"player1": move2, "player2": move1}
|
||||
|
||||
# Compute rewards for each player based on the win-matrix.
|
||||
r1, r2 = self.WIN_MATRIX[move1, move2]
|
||||
rewards = {"player1": r1, "player2": r2}
|
||||
|
||||
# Terminate the entire episode (for all agents) once 10 moves have been made.
|
||||
terminateds = {"__all__": self.num_moves >= 10}
|
||||
|
||||
# Leave truncateds and infos empty.
|
||||
return observations, rewards, terminateds, {}, {}
|
||||
|
||||
|
||||
# __sphinx_doc_5_end__
|
||||
@@ -0,0 +1,183 @@
|
||||
# __sphinx_doc_1_begin__
|
||||
import random
|
||||
|
||||
import gymnasium as gym
|
||||
import numpy as np
|
||||
|
||||
from ray.rllib.env.multi_agent_env import MultiAgentEnv
|
||||
|
||||
|
||||
class TicTacToe(MultiAgentEnv):
|
||||
"""A two-player game in which any player tries to complete one row in a 3x3 field.
|
||||
|
||||
The observation space is Box(-1.0, 1.0, (9,)), where each index represents a distinct
|
||||
field on a 3x3 board. From the current player's perspective: 1.0 means we occupy the
|
||||
field, -1.0 means the opponent owns the field, and 0.0 means the field is empty:
|
||||
----------
|
||||
| 0| 1| 2|
|
||||
----------
|
||||
| 3| 4| 5|
|
||||
----------
|
||||
| 6| 7| 8|
|
||||
----------
|
||||
|
||||
The action space is Discrete(9). Actions landing on an already occupied field
|
||||
result in a -1.0 penalty for the player taking the invalid action.
|
||||
|
||||
Once a player completes a row, they receive +1.0 reward, the losing player receives
|
||||
-1.0 reward. A draw results in 0.0 reward for both players.
|
||||
"""
|
||||
|
||||
# __sphinx_doc_1_end__
|
||||
|
||||
# Winning line indices: rows, columns, and diagonals.
|
||||
WIN_LINES = [
|
||||
[0, 1, 2], # rows
|
||||
[3, 4, 5],
|
||||
[6, 7, 8],
|
||||
[0, 3, 6], # cols
|
||||
[1, 4, 7],
|
||||
[2, 5, 8],
|
||||
[0, 4, 8], # diagonals
|
||||
[2, 4, 6],
|
||||
]
|
||||
|
||||
# __sphinx_doc_2_begin__
|
||||
def __init__(self, config=None):
|
||||
super().__init__()
|
||||
|
||||
# Define the agents in the game.
|
||||
self.agents = self.possible_agents = ["player1", "player2"]
|
||||
|
||||
# Each agent observes a 9D tensor, representing the 3x3 fields of the board.
|
||||
# From the current player's perspective: 1 means our piece, -1 means opponent's
|
||||
# piece, 0 means empty. The board is flipped after each turn.
|
||||
self.observation_spaces = {
|
||||
"player1": gym.spaces.Box(-1.0, 1.0, (9,), np.float32),
|
||||
"player2": gym.spaces.Box(-1.0, 1.0, (9,), np.float32),
|
||||
}
|
||||
# Each player has 9 actions, encoding the 9 fields each player can place a piece
|
||||
# on during their turn.
|
||||
self.action_spaces = {
|
||||
"player1": gym.spaces.Discrete(9),
|
||||
"player2": gym.spaces.Discrete(9),
|
||||
}
|
||||
self.max_timesteps = 30
|
||||
|
||||
self.board = None
|
||||
self.current_player = None
|
||||
self.timestep = 0
|
||||
|
||||
# __sphinx_doc_2_end__
|
||||
|
||||
# __sphinx_doc_3_begin__
|
||||
def reset(self, *, seed=None, options=None):
|
||||
self.board = [0] * 9
|
||||
|
||||
# Pick a random player to start the game and reset the current timesteps.
|
||||
self.current_player = random.choice(self.agents)
|
||||
self.timestep = 0
|
||||
|
||||
# Return observations dict (only with the starting player, which is the one
|
||||
# we expect to act next).
|
||||
return {self.current_player: np.array(self.board, np.float32)}, {}
|
||||
|
||||
# __sphinx_doc_3_end__
|
||||
|
||||
# __sphinx_doc_4_begin__
|
||||
def step(self, action_dict):
|
||||
action = action_dict[self.current_player]
|
||||
opponent = "player2" if self.current_player == "player1" else "player1"
|
||||
self.timestep += 1
|
||||
|
||||
# Invalid move: penalize and return without changing board.
|
||||
if self.board[action] != 0:
|
||||
# The time limit is reached
|
||||
if self.timestep >= self.max_timesteps:
|
||||
board_arr = np.array(self.board, np.float32)
|
||||
return (
|
||||
{self.current_player: board_arr, opponent: board_arr * -1},
|
||||
{self.current_player: -0.5, opponent: 0.0},
|
||||
{"__all__": False},
|
||||
{"__all__": True},
|
||||
{},
|
||||
)
|
||||
else:
|
||||
reward = {self.current_player: -0.5}
|
||||
self.board = [-x for x in self.board]
|
||||
self.current_player = opponent
|
||||
return (
|
||||
{opponent: np.array(self.board, np.float32)},
|
||||
reward,
|
||||
{"__all__": False},
|
||||
{"__all__": False},
|
||||
{},
|
||||
)
|
||||
|
||||
# Place the piece on the board.
|
||||
self.board[action] = 1
|
||||
|
||||
# Check for win.
|
||||
if any(all(self.board[i] == 1 for i in line) for line in self.WIN_LINES):
|
||||
board_arr = np.array(self.board, np.float32)
|
||||
return (
|
||||
{self.current_player: board_arr, opponent: board_arr * -1},
|
||||
{self.current_player: 1.0, opponent: -1.0},
|
||||
{"__all__": True},
|
||||
{"__all__": False},
|
||||
{},
|
||||
)
|
||||
|
||||
# Check for draw (board full, no winner).
|
||||
if 0 not in self.board:
|
||||
board_arr = np.array(self.board, np.float32)
|
||||
return (
|
||||
{self.current_player: board_arr, opponent: board_arr * -1},
|
||||
{self.current_player: 0.0, opponent: 0.0},
|
||||
{"__all__": True},
|
||||
{"__all__": False},
|
||||
{},
|
||||
)
|
||||
|
||||
# Check for truncation.
|
||||
if self.timestep >= self.max_timesteps:
|
||||
board_arr = np.array(self.board, np.float32)
|
||||
return (
|
||||
{self.current_player: board_arr, opponent: board_arr * -1},
|
||||
{self.current_player: 0.0, opponent: 0.0},
|
||||
{"__all__": False},
|
||||
{"__all__": True},
|
||||
{},
|
||||
)
|
||||
|
||||
# Continue game: flip board and switch player.
|
||||
reward = {self.current_player: 0.0}
|
||||
self.board = [-x for x in self.board]
|
||||
self.current_player = opponent
|
||||
|
||||
return (
|
||||
{opponent: np.array(self.board, np.float32)},
|
||||
reward,
|
||||
{"__all__": False},
|
||||
{"__all__": False},
|
||||
{},
|
||||
)
|
||||
|
||||
# __sphinx_doc_4_end__
|
||||
|
||||
def render(self) -> str:
|
||||
"""Render the current board state as an ASCII grid.
|
||||
|
||||
Returns:
|
||||
A string representation of the board where:
|
||||
- 'X' represents the current player's pieces
|
||||
- 'O' represents opponent player's pieces
|
||||
- ' ' represents empty fields
|
||||
"""
|
||||
symbols = {0: " ", 1: "X", -1: "O"}
|
||||
rows = []
|
||||
for i in range(3):
|
||||
row_cells = [symbols[self.board[i * 3 + j]] for j in range(3)]
|
||||
rows.append(" " + " | ".join(row_cells) + " ")
|
||||
separator = "-----------"
|
||||
return "\n" + f"\n{separator}\n".join(rows) + "\n"
|
||||
@@ -0,0 +1,125 @@
|
||||
import numpy as np
|
||||
from gymnasium.spaces import Dict, Discrete, MultiDiscrete, Tuple
|
||||
|
||||
from ray.rllib.env.multi_agent_env import ENV_STATE, MultiAgentEnv
|
||||
|
||||
|
||||
class TwoStepGame(MultiAgentEnv):
|
||||
action_space = Discrete(2)
|
||||
|
||||
def __init__(self, env_config):
|
||||
super().__init__()
|
||||
self.action_space = Discrete(2)
|
||||
self.state = None
|
||||
self.agent_1 = 0
|
||||
self.agent_2 = 1
|
||||
# MADDPG emits action logits instead of actual discrete actions
|
||||
self.actions_are_logits = env_config.get("actions_are_logits", False)
|
||||
self.one_hot_state_encoding = env_config.get("one_hot_state_encoding", False)
|
||||
self.with_state = env_config.get("separate_state_space", False)
|
||||
self._agent_ids = {0, 1}
|
||||
if not self.one_hot_state_encoding:
|
||||
self.observation_space = Discrete(6)
|
||||
self.with_state = False
|
||||
else:
|
||||
# Each agent gets the full state (one-hot encoding of which of the
|
||||
# three states are active) as input with the receiving agent's
|
||||
# ID (1 or 2) concatenated onto the end.
|
||||
if self.with_state:
|
||||
self.observation_space = Dict(
|
||||
{
|
||||
"obs": MultiDiscrete([2, 2, 2, 3]),
|
||||
ENV_STATE: MultiDiscrete([2, 2, 2]),
|
||||
}
|
||||
)
|
||||
else:
|
||||
self.observation_space = MultiDiscrete([2, 2, 2, 3])
|
||||
|
||||
def reset(self, *, seed=None, options=None):
|
||||
if seed is not None:
|
||||
np.random.seed(seed)
|
||||
self.state = np.array([1, 0, 0])
|
||||
return self._obs(), {}
|
||||
|
||||
def step(self, action_dict):
|
||||
if self.actions_are_logits:
|
||||
action_dict = {
|
||||
k: np.random.choice([0, 1], p=v) for k, v in action_dict.items()
|
||||
}
|
||||
|
||||
state_index = np.flatnonzero(self.state)
|
||||
if state_index == 0:
|
||||
action = action_dict[self.agent_1]
|
||||
assert action in [0, 1], action
|
||||
if action == 0:
|
||||
self.state = np.array([0, 1, 0])
|
||||
else:
|
||||
self.state = np.array([0, 0, 1])
|
||||
global_rew = 0
|
||||
terminated = False
|
||||
elif state_index == 1:
|
||||
global_rew = 7
|
||||
terminated = True
|
||||
else:
|
||||
if action_dict[self.agent_1] == 0 and action_dict[self.agent_2] == 0:
|
||||
global_rew = 0
|
||||
elif action_dict[self.agent_1] == 1 and action_dict[self.agent_2] == 1:
|
||||
global_rew = 8
|
||||
else:
|
||||
global_rew = 1
|
||||
terminated = True
|
||||
|
||||
rewards = {self.agent_1: global_rew / 2.0, self.agent_2: global_rew / 2.0}
|
||||
obs = self._obs()
|
||||
terminateds = {"__all__": terminated}
|
||||
truncateds = {"__all__": False}
|
||||
infos = {
|
||||
self.agent_1: {"done": terminateds["__all__"]},
|
||||
self.agent_2: {"done": terminateds["__all__"]},
|
||||
}
|
||||
return obs, rewards, terminateds, truncateds, infos
|
||||
|
||||
def _obs(self):
|
||||
if self.with_state:
|
||||
return {
|
||||
self.agent_1: {"obs": self.agent_1_obs(), ENV_STATE: self.state},
|
||||
self.agent_2: {"obs": self.agent_2_obs(), ENV_STATE: self.state},
|
||||
}
|
||||
else:
|
||||
return {self.agent_1: self.agent_1_obs(), self.agent_2: self.agent_2_obs()}
|
||||
|
||||
def agent_1_obs(self):
|
||||
if self.one_hot_state_encoding:
|
||||
return np.concatenate([self.state, [1]])
|
||||
else:
|
||||
return np.flatnonzero(self.state)[0]
|
||||
|
||||
def agent_2_obs(self):
|
||||
if self.one_hot_state_encoding:
|
||||
return np.concatenate([self.state, [2]])
|
||||
else:
|
||||
return np.flatnonzero(self.state)[0] + 3
|
||||
|
||||
|
||||
class TwoStepGameWithGroupedAgents(MultiAgentEnv):
|
||||
def __init__(self, env_config):
|
||||
self._agent_ids = {"agents"}
|
||||
|
||||
super().__init__()
|
||||
env = TwoStepGame(env_config)
|
||||
tuple_obs_space = Tuple([env.observation_space, env.observation_space])
|
||||
tuple_act_space = Tuple([env.action_space, env.action_space])
|
||||
|
||||
self.env = env.with_agent_groups(
|
||||
groups={"agents": [0, 1]},
|
||||
obs_space=tuple_obs_space,
|
||||
act_space=tuple_act_space,
|
||||
)
|
||||
self.observation_space = Dict({"agents": self.env.observation_space})
|
||||
self.action_space = Dict({"agents": self.env.action_space})
|
||||
|
||||
def reset(self, *, seed=None, options=None):
|
||||
return self.env.reset(seed=seed, options=options)
|
||||
|
||||
def step(self, actions):
|
||||
return self.env.step(actions)
|
||||
Reference in New Issue
Block a user