Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiplayer over the network #32

Open
wants to merge 46 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
5202bba
Lockstep synchronization server-client prototype
AlanAnxinChen Mar 9, 2024
300b934
rough draft of creation effect
lsuyean Mar 13, 2024
c6e6227
implemented pickup and place container
lsuyean Mar 19, 2024
c25ce36
implemented fill water
lsuyean Mar 20, 2024
7201b5d
Made boil water: bug when creating new objects, no id assigned
lsuyean Mar 21, 2024
4fa2078
boil soup bug fixed
lsuyean Mar 25, 2024
34058a0
finish implementing all actions
lsuyean Mar 25, 2024
c79971f
implemented cook soup test
lsuyean Mar 25, 2024
82a9e36
added conditional delayed effect for cook, boil, and fry; added versi…
lsuyean Mar 27, 2024
5ff34f3
Made server also render game (helps debug)
AlanAnxinChen Mar 28, 2024
34c3714
Client runs responsive local environment with rollback support
AlanAnxinChen Mar 28, 2024
0eba451
updated based on comments
lsuyean Apr 8, 2024
5d3cea2
Server now records gameplay
AlanAnxinChen Apr 10, 2024
704df1f
Recordings can now be replayed
AlanAnxinChen Apr 10, 2024
bc539fe
Added recording video rendering
AlanAnxinChen Apr 10, 2024
3045608
edited goal such that it can include objects not in initial state
lsuyean Apr 10, 2024
9c5710a
Added docker support
AlanAnxinChen Apr 12, 2024
9f1c202
merge main
lsuyean Apr 15, 2024
29863d2
pddl functions uncommented
lsuyean Apr 15, 2024
d7f92e3
fixed bug for station location predicates and no conditional effect f…
lsuyean Apr 15, 2024
d81deea
Implemented multi-agent
lsuyean Apr 15, 2024
2611715
addressed comments, fixed bug in creation effect
lsuyean Apr 22, 2024
9f3eb7e
fixed tests
lsuyean Apr 22, 2024
4c4634f
merge goal
lsuyean Apr 24, 2024
e3882b6
made changes based on comments
lsuyean Apr 24, 2024
e9e8bbd
merge
AlanAnxinChen Apr 29, 2024
05c6ea1
Removed action args
AlanAnxinChen Apr 29, 2024
0b3eb1c
server and client loop support multiagent
AlanAnxinChen Apr 29, 2024
18d988a
replay and render support multiagent
AlanAnxinChen Apr 29, 2024
9bd7bfe
Added single player mode (only works for 1 player environments)
AlanAnxinChen May 7, 2024
b95ede9
docker build
AlanAnxinChen May 7, 2024
ec879a3
merge
AlanAnxinChen May 7, 2024
da460e3
fixed state bug
lsuyean Jun 25, 2024
58b9189
Adjust comments
AlanAnxinChen Sep 20, 2024
1110e7b
Set default behavior to run local loop
AlanAnxinChen Sep 20, 2024
d717eb8
Factored out server and client code
AlanAnxinChen Sep 28, 2024
34faf0c
Add README
AlanAnxinChen Sep 29, 2024
e66a556
Add example cmds to README
AlanAnxinChen Oct 18, 2024
1fef98e
Reorganize main
AlanAnxinChen Oct 18, 2024
22c6ccf
Refactor all other modes to networking folder
AlanAnxinChen Oct 18, 2024
aa26202
Elaborated TODOs
AlanAnxinChen Oct 18, 2024
0c00de9
Documentation fixes
AlanAnxinChen Oct 26, 2024
e707039
Add issues to TODO
AlanAnxinChen Oct 26, 2024
13e7f4c
merge
AlanAnxinChen Nov 2, 2024
d78c14f
Server self-updates
AlanAnxinChen Nov 9, 2024
dc1c7d9
Players exchanged between server and client
AlanAnxinChen Nov 9, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/venv/
**/__pycache__/
*-env
robotouille.egg-info
robotouille.egg-info
recordings
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM python:3.9.6

COPY . .

RUN pip install -r requirements.txt

CMD python main.py --environment_name "cook_soup" --role server
6 changes: 5 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
parser = argparse.ArgumentParser()
parser.add_argument("--environment_name", help="The name of the environment to create.", default="original")
parser.add_argument("--seed", help="The seed to use for the environment.", default=None)
parser.add_argument("--role", help="\"simulator\" for vanilla simulator, \"client\" if client, \"server\" if server, \"single\" if single-player, \"replay\" if replaying, \"render\" if rendering video", default="simulator")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

separate the Robotouille arguments and the networking arguments with a newline and have a comment above the networking parameters explaining where to find the networking README.md

parser.add_argument("--server_display", action="store_true", help="Whether to show the game window as server (ignored for other roles)")
parser.add_argument("--host", help="Host to connect to", default="ws://localhost:8765")
parser.add_argument("--replay", help="Recording to replay", default="")
parser.add_argument("--noisy_randomization", action="store_true", help="Whether to use 'noisy randomization' for procedural generation")
args = parser.parse_args()

simulator(args.environment_name, args.seed, args.noisy_randomization)
simulator(args.environment_name, args.seed, args.role, args.server_display, args.host, args.replay, args.noisy_randomization)
40 changes: 40 additions & 0 deletions networking/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Robotouille Networking README
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd add some common usecase commands so others can copy-paste to get a desired result like

  1. server command with option to display screen
  2. client command with a specific host to connect to
  3. how to replay game data onto the simulator
  4. how to render game data into a video


## Overview

Built into Robotouille is networking support for multiplayer. Robotouille uses an authoritative server, which clients can connect to. Servers record games played on them to collect data.

## Available Modes

Several modes are offered, which can be chosen using the `--role` argument:

1. `server`
2. `client `
3. `single`
4. `replay`
5. `render`
6. `simulator`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: call simulator local and move it to 1 so explanations are in order and the first explanation is how to run Robotouille without networking


## Simulator

This mode runs Robotouille without any networking overhead.

## Server

This mode sets up an Robotouille server. Clients that connect are automatically matchmaked and put together into lobbies. Use argument `display_server` to have the server render active games.

## Client

This mode runs the Robotouille client. Use argument `host` to choose which host to connect to. Defaults to local host.

## Single

This mode runs both the server and client for a single player experience. Server features, such as game recordings, remain available.

## Replay

This mode replays a recorded game through a window. The recording is specified with argument `recording`.

## Render

This mode renders a recording game into a video. The video is exported to the recordings folder. The recording is specified with argument `recording`.
64 changes: 64 additions & 0 deletions networking/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import asyncio
import json
import pickle
import base64
import websockets
import pygame
from robotouille.robotouille_env import create_robotouille_env
from utils.robotouille_input import create_action_from_control

def run_client(environment_name: str, seed: int = 42, host: str="ws://localhost:8765", noisy_randomization: bool = False):
asyncio.run(client_loop(environment_name, seed, host, noisy_randomization))

async def client_loop(environment_name: str, seed: int = 42, host: str="ws://localhost:8765", noisy_randomization: bool = False):
uri = host

async def send_actions(websocket, shared_state):
env = shared_state["env"]
renderer = shared_state["renderer"]
renderer.render(shared_state["obs"], mode='human')
player = shared_state["player"]
online = True
while not shared_state["done"]:
pygame_events = pygame.event.get()
mousedown_events = list(filter(lambda e: e.type == pygame.MOUSEBUTTONDOWN, pygame_events))
keydown_events = list(filter(lambda e: e.type == pygame.KEYDOWN, pygame_events))
action, args = create_action_from_control(env, shared_state["obs"], player, mousedown_events + keydown_events, renderer)

# Use this to simulate disconnect
online = not (pygame.key.get_mods() & pygame.KMOD_CAPS)

if action is not None:
if online:
encoded_action = base64.b64encode(pickle.dumps((action, args))).decode('utf-8')
await websocket.send(json.dumps(encoded_action))
renderer.render(env.get_state(), mode='human')

await asyncio.sleep(0) # Yield control to allow other tasks to run

async def receive_responses(websocket, shared_state):
while not shared_state["done"]:
response = await websocket.recv()
data = json.loads(response)
shared_state["done"] = data["done"]

shared_state["env"].set_state(pickle.loads(base64.b64decode(data["env"])))
shared_state["obs"] = pickle.loads(base64.b64decode(data["obs"]))

async with websockets.connect(uri) as websocket:
env, _, renderer = create_robotouille_env(environment_name, seed, noisy_randomization)
obs, info = env.reset()
shared_state = {"done": False, "env": env, "renderer": renderer, "obs": obs, "player": None}
print("In lobby")

opening_message = await websocket.recv()
print("In game")
opening_data = json.loads(opening_message)
player_index = pickle.loads(base64.b64decode(opening_data["player"]))
player = env.get_state().get_players()[player_index]
shared_state["player"] = player

sender = asyncio.create_task(send_actions(websocket, shared_state))
receiver = asyncio.create_task(receive_responses(websocket, shared_state))
await asyncio.gather(sender, receiver)
# Additional cleanup if necessary
128 changes: 128 additions & 0 deletions networking/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import asyncio
import json
import pickle
import base64
import websockets
import time
from pathlib import Path
from datetime import datetime
from robotouille.robotouille_env import create_robotouille_env

SIMULATE_LATENCY = False
SIMULATED_LATENCY_DURATION = 0.25

def run_server(environment_name: str, seed: int=42, noisy_randomization: bool=False, display_server: bool=False, event: asyncio.Event=None):
asyncio.run(server_loop(environment_name, seed, noisy_randomization, display_server))

async def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=False, display_server: bool=False, event: asyncio.Event=None):
waiting_queue = {}
reference_env = create_robotouille_env(environment_name, seed, noisy_randomization)[0]
num_players = len(reference_env.get_state().get_players())

async def simulator(connections):
try:
print("Start game", connections.keys())
recording = {}
recording["start_time"] = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
recording["environment_name"] = environment_name
recording["seed"] = seed
recording["noisy_randomization"] = noisy_randomization
recording["actions"] = []
recording["violations"] = []
start_time = time.monotonic()

env, json_data, renderer = create_robotouille_env(environment_name, seed, noisy_randomization)
obs, info = env.reset()
if display_server:
renderer.render(obs, mode='human')
done = False
interactive = False # Adjust based on client commands later if needed

assert len(connections) == num_players
sockets_to_playerID = {}
for i, socket in enumerate(connections.keys()):
sockets_to_playerID[socket] = i
player_data = base64.b64encode(pickle.dumps(i)).decode('utf-8')
opening_message = json.dumps({"player": player_data})
await socket.send(opening_message)

while not done:
# Wait for messages from any client
# TODO(aac77): make more robust
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make this TODO more specific - what does 'more robust' mean? create an actionable github issue and refer to it in this PR so we know to address this in the future.

receive_tasks = {asyncio.create_task(q.get()): client for client, q in connections.items()}
finished_tasks, pending_tasks = await asyncio.wait(receive_tasks.keys(), return_when=asyncio.FIRST_COMPLETED)

# Cancel pending tasks, otherwise we leak
for task in pending_tasks:
task.cancel()

# Retrieve the message from the completed task
actions = [(None, None)] * num_players
for task in finished_tasks:
message = task.result()
client = receive_tasks[task]
encoded_action = json.loads(message)
action = pickle.loads(base64.b64decode(encoded_action))

actions[sockets_to_playerID[client]] = action


if SIMULATE_LATENCY:
time.sleep(SIMULATED_LATENCY_DURATION)

reply = None

try:
obs, reward, done, info = env.step(actions, interactive=interactive)
recording["actions"].append((actions, env.get_state(), time.monotonic() - start_time))
if display_server:
renderer.render(obs, mode='human')
except AssertionError:
print("violation")
recording["violations"].append((actions, time.monotonic() - start_time))

env_data = pickle.dumps(env.get_state())
encoded_env_data = base64.b64encode(env_data).decode('utf-8')
obs_data = pickle.dumps(obs)
encoded_obs_data = base64.b64encode(obs_data).decode('utf-8')
reply = json.dumps({"env": encoded_env_data, "obs": encoded_obs_data, "done": done})

if SIMULATE_LATENCY:
time.sleep(SIMULATED_LATENCY_DURATION)
websockets.broadcast(connections.keys(), reply)
recording["result"] = "done"
except BaseException as e:
traceback.print_exc(e)
recording["result"] = traceback.format_exc(e)
finally:
for websocket in connections.keys():
await websocket.close()

if display_server:
renderer.render(obs, close=True)
print("GG")
recording["end_time"] = datetime.now().strftime("%Y%m%d_%H%M%S_%f")

p = Path('recordings')
p.mkdir(exist_ok=True)
with open(p / (recording["start_time"] + '.pkl'), 'wb') as f:
pickle.dump(recording, f)

async def handle_connection(websocket):
# TODO(aac77): make more robust
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

print("Hello client", websocket)
q = asyncio.Queue()
waiting_queue[websocket] = q
if len(waiting_queue) == num_players:
connections = waiting_queue.copy()
waiting_queue.clear()
asyncio.create_task(simulator(connections))
async for message in websocket:
await q.put(message)

if event == None:
event = asyncio.Event()

async with websockets.serve(handle_connection, "0.0.0.0", 8765):
print("I am server")
await event.wait()
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ fonttools==4.38.0
gym==0.26.2
gym-notices==0.0.8
imageio==2.25.1
imageio-ffmpeg==0.4.9
importlib-metadata==6.0.0
importlib-resources==5.12.0
kiwisolver==1.4.4
Expand All @@ -21,4 +22,5 @@ scikit-image==0.19.3
scipy==1.10.0
six==1.16.0
tifffile==2023.2.3
websockets==12.0
zipp==3.14.0
2 changes: 1 addition & 1 deletion robotouille/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ def build_state(domain_json, environment_json):
true_predicates += build_stacking_predicates(environment_json)
goal = build_goal(environment_json)

state = State().initialize(domain, objects, true_predicates, goal)
state = State().initialize(domain, objects, true_predicates, goal, [])

return state

Expand Down
92 changes: 90 additions & 2 deletions robotouille/robotouille_simulator.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,97 @@
import pygame
from utils.robotouille_input import create_action_from_control
from robotouille.robotouille_env import create_robotouille_env
import asyncio
import json
import pickle
import base64
import websockets
import time
from pathlib import Path
from datetime import datetime
import imageio
import traceback
import networking.server as robotouille_server
import networking.client as robotouille_client

def simulator(environment_name: str, seed: int=42, role: str="simulator", display_server: bool=False, host: str="ws://localhost:8765", recording: str="", noisy_randomization: bool=False):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

biggest feedback is we want to move as much of the networking related code to the networking directory. I'd like to keep this file as clean as possible to the original so someone that looks at this function can easily reason how Robotouille works without needing to have a coupled understanding on the networking. I suggest moving these if statements to a function call within the networking directory and calling simulate immediately if the role is appropriate. Here is some psuedocode for what I have in mind to

def simulator(...):
"""...docstring..."""
if role != "local":
  # Run networked Robotouille
  networked_simulate(...)
simulate(...)

roles replay and render also make sense under networking since they're utilities for replaying or rendering data generated by the networking class.

if recording != "" and role != "replay" and role != "render":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a comment to this if statement since it takes too long to reason about why we're defaulting to replay if a recording is specified for a gameplaying role

role = "replay"
if role == "server":
robotouille_server.run_server(environment_name, seed, noisy_randomization, display_server)
elif role == "client":
robotouille_client.run_client(environment_name, seed, host, noisy_randomization)
elif role == "single":
asyncio.run(single_player(environment_name, seed, noisy_randomization))
elif role == "replay":
replay(recording)
elif role == "render":
render(recording)
elif role == "simulator":
simulate(environment_name, seed, noisy_randomization)
else:
print("Invalid role:", role)

def simulator(environment_name: str, seed: int=42, noisy_randomization: bool=False):
async def single_player(environment_name: str, seed: int=42, noisy_randomization: bool=False):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we move the networking related code to a utils directory under the networking directory (functions single_player, replay, render)

event = asyncio.Event()
server = asyncio.create_task(robotouille_server.server_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization, event=event))
await asyncio.sleep(0.5) # wait for server to initialize
client = asyncio.create_task(robotouille_client.client_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization))
await client
event.set()
await server

def replay(recording_name: str):
if not recording_name:
raise ValueError("Empty recording_name supplied")

p = Path('recordings')
with open(p / (recording_name + '.pkl'), 'rb') as f:
recording = pickle.load(f)

env, _, renderer = create_robotouille_env(recording["environment_name"], recording["seed"], recording["noisy_randomization"])
obs, _ = env.reset()
renderer.render(obs, mode='human')

previous_time = 0
for actions, state, t in recording["actions"]:
time.sleep(t - previous_time)
previous_time = t
obs, reward, done, info = env.step(actions=actions, interactive=False)
renderer.render(obs, mode='human')
renderer.render(obs, close=True)

def render(recording_name: str):
p = Path('recordings')
with open(p / (recording_name + '.pkl'), 'rb') as f:
recording = pickle.load(f)

env, _, renderer = create_robotouille_env(recording["environment_name"], recording["seed"], recording["noisy_randomization"])
obs, _ = env.reset()
frame = renderer.render(obs, mode='rgb_array')

vp = Path('recordings')
vp.mkdir(exist_ok=True)
fps = 20
video_writer = imageio.get_writer(vp / (recording_name + '.mp4'), fps=fps)

i = 0
t = 0
while i < len(recording["actions"]):
actions, state, time_stamp = recording["actions"][i]
while t > time_stamp:
obs, reward, done, info = env.step(actions=actions, interactive=False)
frame = renderer.render(obs, mode='rgb_array')
i += 1
if i >= len(recording["actions"]):
break
action, state, time_stamp = recording["actions"][i]
t += 1 / fps
video_writer.append_data(frame)
renderer.render(obs, close=True)
video_writer.close()

def simulate(environment_name, seed, noisy_randomization):
# Your code for robotouille goes here
env, json, renderer = create_robotouille_env(environment_name, seed, noisy_randomization)
obs, info = env.reset()
Expand All @@ -30,4 +118,4 @@ def simulator(environment_name: str, seed: int=42, noisy_randomization: bool=Fal
continue
obs, reward, done, info = env.step(actions, interactive=interactive)
renderer.render(obs, mode='human')
renderer.render(obs, close=True)
renderer.render(obs, close=True)