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) --------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user