API: in-process boti ako hraci
Boti su sedadla bez socketu: ucty bot:<kind>-<n> v tabulke players (nehijacknutelne, recyklovane medzi hrami), handlery add_bot/remove_bot (len hostitel, pred startom) a tahova slucka _run_bot_turns s pauzou BOT_MOVE_DELAY_SECONDS a MC/inferenciou v executori. Druhy: heuristic, random, neural (pure-Python siet; bez suboru vah jasna chyba a pri restore fallback na heuristiku). Botie sedadla preziju restart servera. Oprava po ceste: emit kariet hracovi so sid=None (offline sedadlo po restore) broadcastoval jeho karty vsetkym klientom -- preskakuje sa. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+150
-5
@@ -13,8 +13,10 @@ import socketio
|
||||
from bridzik import Bridzik, BridzikException, Card
|
||||
from db.db import init_db
|
||||
from api import auth as auth_module, history
|
||||
from api import bots as bots_module
|
||||
from api import stats as stats_module
|
||||
from api.auth import AuthError, RegistrationIncomplete
|
||||
from rl.encoding import index_card
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -179,6 +181,9 @@ class Game:
|
||||
self.players: list["Player"] = []
|
||||
self.started = False
|
||||
self.bridzik_core: Bridzik | None = None
|
||||
# Serializuje tahovu slucku botov -- dva sucasne _run_bot_turns tasky
|
||||
# by inak mohli tahat za to iste sedadlo.
|
||||
self.bot_lock = asyncio.Lock()
|
||||
|
||||
def start(self):
|
||||
self.bridzik_core = Bridzik()
|
||||
@@ -202,6 +207,10 @@ class Player:
|
||||
self.player_id = player_id # persistent account id (db.models.Player.id)
|
||||
self.token = str(uuid.uuid4()) # secret token used for secure reconnect
|
||||
self.connected = True
|
||||
# Bot = sedadlo bez socketu; `brain` je rozhodovaci objekt s rozhranim
|
||||
# guess(rnd, seat) / play(rnd, seat) z rl/players.py.
|
||||
self.is_bot = False
|
||||
self.brain = None
|
||||
|
||||
|
||||
class CardStatusEncoder(JSONEncoder):
|
||||
@@ -232,6 +241,7 @@ def public_games() -> list:
|
||||
"name": p.name,
|
||||
"connected": p.connected,
|
||||
"player_id": p.player_id,
|
||||
"is_bot": p.is_bot,
|
||||
}
|
||||
for p in g.players
|
||||
],
|
||||
@@ -262,7 +272,8 @@ async def send_game_status(gid: str):
|
||||
"completed": core.is_completed(),
|
||||
# Self-contained roster so the game view doesn't depend on the lobby snapshot.
|
||||
"players": [
|
||||
{"order": p.order, "name": p.name, "connected": p.connected}
|
||||
{"order": p.order, "name": p.name, "connected": p.connected,
|
||||
"is_bot": p.is_bot}
|
||||
for p in sorted(game.players, key=lambda p: p.order)
|
||||
],
|
||||
"series_number": core.series[-1].series_number,
|
||||
@@ -299,11 +310,16 @@ async def _cleanup_abandoned_lobby(gid: str):
|
||||
task neskodny no-op (netreba nic rusit)."""
|
||||
await asyncio.sleep(LOBBY_ABANDON_GRACE_SECONDS)
|
||||
game = games.get(gid)
|
||||
if game is not None and not game.started and not any(p.connected for p in game.players):
|
||||
if game is not None and not game.started and not _any_human_connected(game):
|
||||
del games[gid]
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
def _any_human_connected(game: "Game") -> bool:
|
||||
"""Boti su 'pripojeni' stale, pre opustenost lobby sa pocitaju len ludia."""
|
||||
return any(p.connected for p in game.players if not p.is_bot)
|
||||
|
||||
|
||||
async def _mark_player_offline(game: "Game", player: "Player"):
|
||||
"""Mark player disconnected. An unstarted game with nobody left gets a
|
||||
delayed cleanup (mobile sockets drop on screen lock, so an immediate
|
||||
@@ -311,7 +327,7 @@ async def _mark_player_offline(game: "Game", player: "Player"):
|
||||
is kept in memory so it stays in the lobby and can be resumed (it's torn
|
||||
down only by end_game)."""
|
||||
player.connected = False
|
||||
if not any(p.connected for p in game.players) and not game.started:
|
||||
if not _any_human_connected(game) and not game.started:
|
||||
asyncio.create_task(_cleanup_abandoned_lobby(game.gid))
|
||||
await sio.emit(
|
||||
"player_connection",
|
||||
@@ -352,11 +368,75 @@ def _load_game_into_memory(info: dict) -> "Game":
|
||||
for seat, (pid, uname) in enumerate(info["seats"]):
|
||||
player = Player(None, uname, seat, pid)
|
||||
player.connected = False
|
||||
# Boti sa rozpoznaju konvenciou mena a dostanu novy mozog -- ozivi ich
|
||||
# prvy _kick_bots (napr. ked sa clovek vrati cez rejoin_game).
|
||||
if bots_module.is_bot_username(uname):
|
||||
player.is_bot = True
|
||||
player.brain = bots_module.make_brain(uname)
|
||||
player.connected = True
|
||||
game.players.append(player)
|
||||
games[info["gid"]] = game
|
||||
return game
|
||||
|
||||
|
||||
# --- bot turns --------------------------------------------------------------
|
||||
|
||||
# Pauza medzi tahmi bota, nech ludia stihaju sledovat hru (0 = okamzite).
|
||||
BOT_MOVE_DELAY_SECONDS = float(os.environ.get("BOT_MOVE_DELAY_SECONDS", "0.8"))
|
||||
|
||||
|
||||
def _kick_bots(gid: str) -> None:
|
||||
"""Ak je v hre bot, spusti (na pozadi) dohratie vsetkych botich tahov.
|
||||
Vola sa po kazdej akcii, ktora mohla posunut tah na botie sedadlo."""
|
||||
game = games.get(gid)
|
||||
if game is not None and game.started and any(p.is_bot for p in game.players):
|
||||
asyncio.create_task(_run_bot_turns(gid))
|
||||
|
||||
|
||||
async def _run_bot_turns(gid: str):
|
||||
"""Kym je na tahu botie sedadlo, vykonavaj jeho tahy tym istym internym
|
||||
postupom ako handlery add_guess/play_card (engine validuje, historia sa
|
||||
zapisuje, room dostava game_status). MC vypocet bezi v executori, aby
|
||||
nedrzal event loop ostatnych hier."""
|
||||
game = games.get(gid)
|
||||
if game is None or not game.started or game.bridzik_core is None:
|
||||
return
|
||||
async with game.bot_lock:
|
||||
core = game.bridzik_core
|
||||
loop = asyncio.get_running_loop()
|
||||
while not core.is_completed():
|
||||
rnd = core.series[-1].get_last_round()
|
||||
seat = rnd.get_active_player()
|
||||
bot = game.player_by_order(seat)
|
||||
if bot is None or not bot.is_bot:
|
||||
return # na tahu je clovek
|
||||
if BOT_MOVE_DELAY_SECONDS > 0:
|
||||
await asyncio.sleep(BOT_MOVE_DELAY_SECONDS)
|
||||
if games.get(gid) is not game:
|
||||
return # hru medzitym niekto ukoncil (end_game)
|
||||
try:
|
||||
if not rnd.is_guessing_completed():
|
||||
guess = await loop.run_in_executor(None, bot.brain.guess, rnd, seat)
|
||||
core.add_player_guess(seat, guess)
|
||||
else:
|
||||
action = await loop.run_in_executor(None, bot.brain.play, rnd, seat)
|
||||
core.play_card(seat, index_card(action))
|
||||
await history.record_completed_rounds(gid, core)
|
||||
for player in game.players:
|
||||
if player.sid:
|
||||
await send_player_cards(gid, player.order, player.sid)
|
||||
except BridzikException as exc:
|
||||
# Nemalo by nastat (bot hra len legalne tahy) -- nezacykli sa,
|
||||
# slucku znovu spusti dalsia akcia cloveka.
|
||||
await send_error_room(gid, str(exc))
|
||||
return
|
||||
await send_game_status(gid)
|
||||
|
||||
|
||||
async def send_error_room(gid: str, message: str):
|
||||
await sio.emit("error", {"error": message}, room=gid)
|
||||
|
||||
|
||||
# --- connection lifecycle -------------------------------------------------
|
||||
|
||||
@sio.event
|
||||
@@ -488,6 +568,62 @@ async def register_player(sid, gid):
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
@sio.on("add_bot")
|
||||
async def add_bot(sid, gid, kind=None):
|
||||
"""Hostitel prida bota na najnizsie volne sedadlo nezacatej hry."""
|
||||
sess = sessions.get(sid)
|
||||
if sess is None or sess["gid"] != gid:
|
||||
return await send_error(sid, "Nie ste v tejto hre.")
|
||||
if sess["order"] != 0:
|
||||
return await send_error(sid, "Iba hostitel moze pridavat botov.")
|
||||
game = games.get(gid)
|
||||
if game is None:
|
||||
return await send_error(sid, "Hra neexistuje.")
|
||||
if game.started:
|
||||
return await send_error(sid, "Hra uz zacala.")
|
||||
if len(game.players) >= 4:
|
||||
return await send_error(sid, "Prekroceny pocet hracov.")
|
||||
|
||||
if kind == "neural" and not bots_module.neural_available():
|
||||
return await send_error(sid, "AI bot nie je na tomto serveri dostupny.")
|
||||
if kind not in bots_module.BOT_KINDS:
|
||||
kind = bots_module.DEFAULT_KIND
|
||||
account = await bots_module.ensure_bot_account(
|
||||
kind, {p.player_id for p in game.players}
|
||||
)
|
||||
used = {p.order for p in game.players}
|
||||
order = next(o for o in range(4) if o not in used)
|
||||
player = Player(None, account["username"], order, account["player_id"])
|
||||
player.is_bot = True
|
||||
player.brain = bots_module.make_brain(account["username"])
|
||||
game.players.append(player)
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
@sio.on("remove_bot")
|
||||
async def remove_bot(sid, gid, order):
|
||||
"""Hostitel odoberie bota z nezacatej hry (sedadlo sa uvolni)."""
|
||||
sess = sessions.get(sid)
|
||||
if sess is None or sess["gid"] != gid:
|
||||
return await send_error(sid, "Nie ste v tejto hre.")
|
||||
if sess["order"] != 0:
|
||||
return await send_error(sid, "Iba hostitel moze odoberat botov.")
|
||||
game = games.get(gid)
|
||||
if game is None:
|
||||
return await send_error(sid, "Hra neexistuje.")
|
||||
if game.started:
|
||||
return await send_error(sid, "Hra uz zacala.")
|
||||
try:
|
||||
seat = int(order)
|
||||
except (TypeError, ValueError):
|
||||
return await send_error(sid, "Neplatne sedadlo.")
|
||||
player = game.player_by_order(seat)
|
||||
if player is None or not player.is_bot:
|
||||
return await send_error(sid, "Na tomto sedadle nie je bot.")
|
||||
game.players.remove(player)
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
@sio.on("leave_game")
|
||||
async def leave_game(sid):
|
||||
"""Explicit exit (e.g. a 'Back to lobby' button). The socket stays
|
||||
@@ -535,7 +671,11 @@ async def start_game(sid, gid):
|
||||
await broadcast_lobby()
|
||||
await send_game_status(gid)
|
||||
for player in game.players:
|
||||
await send_player_cards(gid, player.order, player.sid)
|
||||
# sid None = bot alebo offline sedadlo -- emit s to=None by karty
|
||||
# broadcastol VSETKYM klientom, preto sa preskakuje.
|
||||
if player.sid:
|
||||
await send_player_cards(gid, player.order, player.sid)
|
||||
_kick_bots(gid)
|
||||
|
||||
|
||||
@sio.on("end_game")
|
||||
@@ -590,6 +730,7 @@ async def reconnect_to_game(sid, gid, token):
|
||||
await send_player_cards(gid, player.order, sid)
|
||||
await sio.emit("player_connection", {"order": player.order, "connected": True}, room=gid)
|
||||
await broadcast_lobby()
|
||||
_kick_bots(gid)
|
||||
|
||||
|
||||
@sio.on("rejoin_game")
|
||||
@@ -627,6 +768,7 @@ async def rejoin_game(sid, gid):
|
||||
await send_player_cards(gid, player.order, sid)
|
||||
await sio.emit("player_connection", {"order": player.order, "connected": True}, room=gid)
|
||||
await broadcast_lobby()
|
||||
_kick_bots(gid)
|
||||
|
||||
|
||||
@sio.on("restore_game")
|
||||
@@ -685,6 +827,7 @@ async def add_guess(sid, guess):
|
||||
except BridzikException as exc:
|
||||
return await send_error(sid, str(exc))
|
||||
await send_game_status(game.gid)
|
||||
_kick_bots(game.gid)
|
||||
|
||||
|
||||
@sio.on("play_card")
|
||||
@@ -710,12 +853,14 @@ async def play_card(sid, card_key):
|
||||
await history.record_completed_rounds(game.gid, core)
|
||||
await send_game_status(game.gid)
|
||||
for player in game.players:
|
||||
await send_player_cards(game.gid, player.order, player.sid)
|
||||
if player.sid: # None (bot/offline) by broadcastoval karty vsetkym
|
||||
await send_player_cards(game.gid, player.order, player.sid)
|
||||
# A naturally-finished game (all 4 series played out) has nothing left to
|
||||
# offer the lobby -- refresh the list so it drops out immediately instead
|
||||
# of lingering as "started"/resumable until someone happens to leave it.
|
||||
if core.is_completed():
|
||||
await broadcast_lobby()
|
||||
_kick_bots(game.gid)
|
||||
|
||||
|
||||
# --- history (read-only) --------------------------------------------------
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
"""In-process boti: DB ucty botov a ich rozhodovacie "mozgy" z rl/players.py.
|
||||
|
||||
Bot je normalny hrac na sedadle -- ma riadok v tabulke `players` (aby
|
||||
historia, standings a restore fungovali bez zmeny), ale ziadny socket.
|
||||
Tahovu slucku botov ma api/__init__.py (_run_bot_turns); tu je len to,
|
||||
co potrebuje DB a rl vrstvu.
|
||||
|
||||
Bezpecnost botich uctov: totp_secret je nahodny a nikde sa neuklada v
|
||||
citatelnej podobe, totp_last_step sa nastavi na aktualny krok -- ucet tym
|
||||
padom NIE JE "nedokoncena registracia" (viz auth._is_unconfirmed), takze
|
||||
register_account ho odmietne prepisat a login bez secretu neprejde.
|
||||
"""
|
||||
|
||||
import os
|
||||
from random import Random
|
||||
|
||||
import pyotp
|
||||
from sqlalchemy import select
|
||||
|
||||
from api import auth
|
||||
from db import crypto
|
||||
from db.db import async_session
|
||||
from db.models import Player
|
||||
from rl.players import HeuristicPlayer, RandomPlayer
|
||||
from rl.pure_net import DEFAULT_WEIGHTS_PATH, PureNet, PureNeuralPlayer
|
||||
|
||||
# Prefix je konvencia na rozpoznanie bota (aj po restarte servera, kedy sa
|
||||
# sedadla obnovuju z DB len ako (player_id, username)).
|
||||
BOT_PREFIX = "bot:"
|
||||
DEFAULT_KIND = "heuristic"
|
||||
|
||||
# Natrenovana siet -- vahy (rl/weights/, export z rl/export.py) sa nacitaju
|
||||
# raz a zdielaju medzi botmi (PureNet je bezstavovy, len cita).
|
||||
_pure_net: PureNet | None = None
|
||||
|
||||
|
||||
def _neural_brain() -> PureNeuralPlayer:
|
||||
global _pure_net
|
||||
if _pure_net is None:
|
||||
_pure_net = PureNet.load()
|
||||
return PureNeuralPlayer(_pure_net)
|
||||
|
||||
|
||||
def neural_available() -> bool:
|
||||
return os.path.exists(DEFAULT_WEIGHTS_PATH)
|
||||
|
||||
|
||||
_BRAINS = {
|
||||
"heuristic": lambda: HeuristicPlayer(Random()),
|
||||
"random": lambda: RandomPlayer(Random()),
|
||||
"neural": _neural_brain,
|
||||
}
|
||||
BOT_KINDS = tuple(_BRAINS)
|
||||
|
||||
|
||||
def available_kinds() -> tuple:
|
||||
"""Druhy botov ponuknutelne na tomto serveri (neural len s vahami)."""
|
||||
return tuple(k for k in _BRAINS if k != "neural" or neural_available())
|
||||
|
||||
|
||||
def is_bot_username(username: str) -> bool:
|
||||
return bool(username) and username.startswith(BOT_PREFIX)
|
||||
|
||||
|
||||
def kind_of(username: str) -> str:
|
||||
"""'bot:heuristic-2' -> 'heuristic'; neznamy druh padne na DEFAULT_KIND."""
|
||||
body = username[len(BOT_PREFIX):]
|
||||
kind = body.rsplit("-", 1)[0]
|
||||
return kind if kind in _BRAINS else DEFAULT_KIND
|
||||
|
||||
|
||||
def make_brain(username: str):
|
||||
"""Rozhodovaci objekt (guess/play rozhranie z rl/players.py) pre bota.
|
||||
|
||||
Neural bez suboru vah (napr. restore hry na serveri bez exportu) padne
|
||||
na heuristiku -- sedadlo hra dalej, len inym mozgom.
|
||||
"""
|
||||
kind = kind_of(username)
|
||||
if kind == "neural" and not neural_available():
|
||||
kind = DEFAULT_KIND
|
||||
return _BRAINS[kind]()
|
||||
|
||||
|
||||
def _suffix_number(username: str) -> int:
|
||||
try:
|
||||
return int(username.rsplit("-", 1)[1])
|
||||
except (IndexError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
async def ensure_bot_account(kind: str, exclude_ids: set) -> dict:
|
||||
"""Najde alebo zalozi boti ucet daneho druhu; vrati {player_id, username}.
|
||||
|
||||
`exclude_ids` su ucty uz obsadene v danej hre -- kazde sedadlo potrebuje
|
||||
INY ucet (Game.playerN_id aj unikat v Guess predpokladaju 4 rozne ID).
|
||||
Ucty sa cisluju bot:<kind>-1, -2, ... a recykluju sa medzi hrami.
|
||||
"""
|
||||
prefix = f"{BOT_PREFIX}{kind}-"
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(Player).where(Player.username.like(prefix + "%"))
|
||||
)
|
||||
).all()
|
||||
for player in sorted(rows, key=lambda p: _suffix_number(p.username)):
|
||||
if player.id not in exclude_ids:
|
||||
return {"player_id": player.id, "username": player.username}
|
||||
|
||||
number = 1 + max((_suffix_number(p.username) for p in rows), default=0)
|
||||
username = f"{prefix}{number}"
|
||||
player = Player(
|
||||
username=username,
|
||||
# nahodny secret, ktory sa zahodi -- nikto sa zan neprihlasi
|
||||
totp_secret=crypto.encrypt(pyotp.random_base32()),
|
||||
# nenulovy last_step = ucet sa netvari ako nedokoncena registracia
|
||||
totp_last_step=auth._current_step(),
|
||||
)
|
||||
session.add(player)
|
||||
await session.commit()
|
||||
return {"player_id": player.id, "username": player.username}
|
||||
Reference in New Issue
Block a user