Implement arbiter
This commit is contained in:
parent
69efe9ee28
commit
b18d1abe0c
191
backend/backend/arbiter.py
Normal file
191
backend/backend/arbiter.py
Normal 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)
|
@ -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
|
||||
|
@ -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:
|
||||
|
13
backend/backend/settings.py
Normal file
13
backend/backend/settings.py
Normal 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()
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user