From b18d1abe0c31126ca894641886bac411ca299eb0 Mon Sep 17 00:00:00 2001 From: Kai Vogelgesang Date: Fri, 28 Oct 2022 16:41:25 +0200 Subject: [PATCH] Implement arbiter --- backend/backend/arbiter.py | 191 ++++++++++++++++++++++++++++++++++++ backend/backend/input.py | 4 + backend/backend/pag.py | 4 +- backend/backend/settings.py | 13 +++ backend/backend/web.py | 26 ++--- 5 files changed, 219 insertions(+), 19 deletions(-) create mode 100644 backend/backend/arbiter.py create mode 100644 backend/backend/settings.py diff --git a/backend/backend/arbiter.py b/backend/backend/arbiter.py new file mode 100644 index 0000000..7cc3808 --- /dev/null +++ b/backend/backend/arbiter.py @@ -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) diff --git a/backend/backend/input.py b/backend/backend/input.py index c95408f..5f1efe8 100644 --- a/backend/backend/input.py +++ b/backend/backend/input.py @@ -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 diff --git a/backend/backend/pag.py b/backend/backend/pag.py index 9f1ffea..b88eadd 100644 --- a/backend/backend/pag.py +++ b/backend/backend/pag.py @@ -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: diff --git a/backend/backend/settings.py b/backend/backend/settings.py new file mode 100644 index 0000000..11538e4 --- /dev/null +++ b/backend/backend/settings.py @@ -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() diff --git a/backend/backend/web.py b/backend/backend/web.py index 4a07068..01ea40f 100644 --- a/backend/backend/web.py +++ b/backend/backend/web.py @@ -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()