Implement arbiter

This commit is contained in:
Kai Vogelgesang 2022-10-28 16:41:25 +02:00
parent 69efe9ee28
commit b18d1abe0c
5 changed files with 219 additions and 19 deletions

191
backend/backend/arbiter.py Normal file
View File

@ -0,0 +1,191 @@
import asyncio
from datetime import datetime, timedelta
from dataclasses import dataclass
import random
from socket import socket
from typing import Callable, Awaitable
from fastapi import WebSocket, WebSocketDisconnect
from .settings import settings
from .input import EMPTY_INPUT, Button, Input
from .pag import input as pag_input
InputGetter = Callable[[None], Awaitable[list[Input]]]
OutputSetter = Callable[[Input], None]
ModeFunction = Callable[[InputGetter, OutputSetter], None]
@dataclass
class GameMode:
name: str
mode_function: ModeFunction
allow_multitouch: bool
@dataclass
class State:
mode: str
allowMultitouch: bool | None
nextMode: str
timeUntilNextMode: float
votes: dict[Button, int]
# playerIdle: bool # set individually for each client
class ClientState:
def __init__(self):
self.current_input = EMPTY_INPUT
self.inactivity_task = None
self.is_active = False
def on_input(self, input: Input):
self.current_input = input
self.is_active = True
if self.inactivity_task:
self.inactivity_task.cancel()
self.inactivity_task = asyncio.create_task(self.deactivator())
async def deactivator(self):
await asyncio.sleep(settings.CLIENT_IDLE_TIMEOUT)
self.is_active = False
self.inactivity_task = None
class Arbiter:
def __init__(self):
self.modes: list[GameMode] = []
self.state = State("", False, "", 0, dict())
self.current_mode = None
self.current_mode_task = None
self.modeswitch_time = None
self.clients: dict[WebSocket, ClientState] = dict()
def mode(self, name: str, allow_multitouch: bool = True):
def inner(f: ModeFunction):
self.modes.append(GameMode(name, f, allow_multitouch))
return inner
async def handle_socket(self, socket: WebSocket):
self.clients[socket] = ClientState()
while True:
try:
data = await socket.receive_json()
self.clients[socket].on_input(data)
except WebSocketDisconnect:
break
del self.clients[socket]
async def get_input(self):
# filter players
allowed_multitouch = 1 if self.current_mode.allow_multitouch else 10
active_player_inputs = [
client.current_input
for client in self.clients.values()
if client.is_active
and sum(client.current_input.values()) <= allowed_multitouch
]
# update vote histogram
self.state.votes = {
button: sum(input[button] for input in active_player_inputs)
for button in Button
}
return active_player_inputs
def set_output(self, output: Input):
pag_input.set(output)
def update_next_mode(self):
choices = [mode for mode in self.modes if mode != self.current_mode]
if choices:
self.next_mode = random.choice(choices)
else:
self.next_mode = self.current_mode
self.state.nextMode = self.next_mode.name
async def main_loop(self):
# current_mode is None / ""
self.update_next_mode()
while True:
# switch modes
self.current_mode = self.next_mode
self.state.mode = self.current_mode.name
self.update_next_mode()
self.modeswitch_time = datetime.now() + timedelta(
seconds=settings.ARBITER_MODE_SWITCH_CYCLE
)
if self.current_mode_task:
self.current_mode_task.cancel()
self.current_mode_task = asyncio.create_task(
self.current_mode.mode_function(self.get_input, self.set_output)
)
# send updates until next mode switch
while (now := datetime.now()) < self.modeswitch_time:
self.state.timeUntilNextMode = (
self.modeswitch_time - now
).total_seconds()
await asyncio.gather(
asyncio.sleep(settings.ARBITER_TICK_CYCLE),
*[
socket.send_json(
{
**self.state.__dict__,
"playerIdle": not self.clients[socket].is_active,
}
)
for socket in self.clients
],
return_exceptions=True,
)
arbiter = Arbiter()
@arbiter.mode("democracy", allow_multitouch=False)
async def _(get_input: InputGetter, set_output: OutputSetter):
while True:
await asyncio.sleep(settings.DEMOCRACY_VOTE_CYCLE)
vote = {button: 0 for button in Button}
vote["none"] = 0
inputs: list[Input] = await get_input()
if not inputs:
set_output(EMPTY_INPUT)
continue
for input in inputs:
# since multitouch is not allowed, we can assume that
# at most one entry is true
for button in Button:
if input[button]:
vote[button] += 1
break
else:
# cursed python syntax
vote["none"] += 1
max_choice = None
max_votes = -1
for (choice, votes) in vote.items():
if votes > max_votes:
max_votes = votes
max_choice = choice
output = EMPTY_INPUT
if max_choice != "none":
output[max_choice] = True
set_output(output)

View File

@ -2,6 +2,7 @@ from enum import Enum
from dataclasses import dataclass
from typing import Literal
class Button(str, Enum):
UP = "up"
DOWN = "down"
@ -17,6 +18,8 @@ class Button(str, Enum):
Input = dict[Button, bool]
EMPTY_INPUT = {button: False for button in Button}
KEYMAP = {
Button.UP: "up",
Button.DOWN: "down",
@ -30,6 +33,7 @@ KEYMAP = {
Button.SELECT: "backspace",
}
@dataclass
class Event:
button: Button

View File

@ -1,9 +1,7 @@
import pyautogui
from threading import Thread, Event
from .input import KEYMAP, Input, Button, Event as InputEvent
EMPTY_INPUT = {button: False for button in Button}
from .input import KEYMAP, Input, EMPTY_INPUT, Button, Event as InputEvent
class InputHandler:

View File

@ -0,0 +1,13 @@
from pydantic import BaseSettings
class Settings(BaseSettings):
CLIENT_IDLE_TIMEOUT: float = 10
ARBITER_TICK_CYCLE: float = 0.1
ARBITER_MODE_SWITCH_CYCLE: float = 10
DEMOCRACY_VOTE_CYCLE: float = 0.25
settings = Settings()

View File

@ -6,31 +6,25 @@ from fastapi.responses import FileResponse
from .pag import input as pag_input
from .input import Button
from .arbiter import arbiter
app = FastAPI()
backend = FastAPI()
@app.on_event("startup")
async def on_startup():
print("startup hook")
asyncio.get_running_loop().create_task(arbiter.main_loop())
@backend.websocket("/client")
async def client_handler(socket: WebSocket):
print("WS opened")
print("[web] WS opened")
await socket.accept()
input = {button: False for button in Button}
while True:
try:
data = await socket.receive_json()
print(f"WS data: {data!r}")
button = data["button"]
pag_input.set({**input, button: True})
await asyncio.sleep(0.1)
pag_input.set({**input, button: False})
except WebSocketDisconnect:
break
print("WS closed")
await arbiter.handle_socket(socket)
print("[web] WS closed")
frontend = FastAPI()