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 dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
class Button(str, Enum):
|
class Button(str, Enum):
|
||||||
UP = "up"
|
UP = "up"
|
||||||
DOWN = "down"
|
DOWN = "down"
|
||||||
@ -17,6 +18,8 @@ class Button(str, Enum):
|
|||||||
|
|
||||||
Input = dict[Button, bool]
|
Input = dict[Button, bool]
|
||||||
|
|
||||||
|
EMPTY_INPUT = {button: False for button in Button}
|
||||||
|
|
||||||
KEYMAP = {
|
KEYMAP = {
|
||||||
Button.UP: "up",
|
Button.UP: "up",
|
||||||
Button.DOWN: "down",
|
Button.DOWN: "down",
|
||||||
@ -30,6 +33,7 @@ KEYMAP = {
|
|||||||
Button.SELECT: "backspace",
|
Button.SELECT: "backspace",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Event:
|
class Event:
|
||||||
button: Button
|
button: Button
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import pyautogui
|
import pyautogui
|
||||||
from threading import Thread, Event
|
from threading import Thread, Event
|
||||||
|
|
||||||
from .input import KEYMAP, Input, Button, Event as InputEvent
|
from .input import KEYMAP, Input, EMPTY_INPUT, Button, Event as InputEvent
|
||||||
|
|
||||||
EMPTY_INPUT = {button: False for button in Button}
|
|
||||||
|
|
||||||
|
|
||||||
class InputHandler:
|
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 .pag import input as pag_input
|
||||||
from .input import Button
|
from .input import Button
|
||||||
|
from .arbiter import arbiter
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
backend = 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")
|
@backend.websocket("/client")
|
||||||
async def client_handler(socket: WebSocket):
|
async def client_handler(socket: WebSocket):
|
||||||
print("WS opened")
|
print("[web] WS opened")
|
||||||
await socket.accept()
|
await socket.accept()
|
||||||
|
await arbiter.handle_socket(socket)
|
||||||
input = {button: False for button in Button}
|
print("[web] WS closed")
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
frontend = FastAPI()
|
frontend = FastAPI()
|
||||||
|
Loading…
Reference in New Issue
Block a user