- Remove unused card_revealed broadcast + 1s asyncio.sleep in swap handler (client never handled this message, causing pure dead wait before game_state) - Defer swap-out (opacity:0) on hand cards to onStart callback so overlay covers the card before hiding it — eliminates visual gap for all players - Defer heldCardFloating visibility hide to onStart — held card stays visible until animation overlay replaces it - Thread onStart callback through animateUnifiedSwap → _runUnifiedSwap Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
610 lines
22 KiB
Python
610 lines
22 KiB
Python
"""WebSocket message handlers for the Golf card game.
|
|
|
|
Each handler corresponds to a single message type from the client.
|
|
Handlers are dispatched via the HANDLERS dict in main.py.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
from fastapi import WebSocket
|
|
|
|
from config import config
|
|
from game import GamePhase, GameOptions
|
|
from ai import GolfAI, get_all_profiles
|
|
from room import Room
|
|
from services.game_logger import get_logger
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ConnectionContext:
|
|
"""State tracked per WebSocket connection."""
|
|
|
|
websocket: WebSocket
|
|
connection_id: str
|
|
player_id: str
|
|
auth_user_id: Optional[str]
|
|
authenticated_user: object # Optional[User]
|
|
current_room: Optional[Room] = None
|
|
|
|
|
|
def log_human_action(room: Room, player, action: str, card=None, position=None, reason: str = ""):
|
|
"""Log a human player's game action (shared helper for all handlers)."""
|
|
game_logger = get_logger()
|
|
if game_logger and room.game_log_id and player:
|
|
game_logger.log_move(
|
|
game_id=room.game_log_id,
|
|
player=player,
|
|
is_cpu=False,
|
|
action=action,
|
|
card=card,
|
|
position=position,
|
|
game=room.game,
|
|
decision_reason=reason,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lobby / Room handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
|
if config.INVITE_ONLY and not ctx.authenticated_user:
|
|
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
|
|
return
|
|
|
|
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
|
await ctx.websocket.send_json({
|
|
"type": "error",
|
|
"message": f"Maximum {max_concurrent} concurrent games allowed",
|
|
})
|
|
return
|
|
|
|
# Use authenticated username as player name
|
|
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
|
room = room_manager.create_room()
|
|
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
|
room.touch()
|
|
ctx.current_room = room
|
|
|
|
await ctx.websocket.send_json({
|
|
"type": "room_created",
|
|
"room_code": room.code,
|
|
"player_id": ctx.player_id,
|
|
"authenticated": ctx.authenticated_user is not None,
|
|
})
|
|
|
|
await room.broadcast({
|
|
"type": "player_joined",
|
|
"players": room.player_list(),
|
|
})
|
|
|
|
|
|
async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
|
|
if config.INVITE_ONLY and not ctx.authenticated_user:
|
|
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"})
|
|
return
|
|
|
|
room_code = data.get("room_code", "").upper()
|
|
# Use authenticated username as player name
|
|
player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player")
|
|
|
|
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
|
|
await ctx.websocket.send_json({
|
|
"type": "error",
|
|
"message": f"Maximum {max_concurrent} concurrent games allowed",
|
|
})
|
|
return
|
|
|
|
room = room_manager.get_room(room_code)
|
|
if not room:
|
|
await ctx.websocket.send_json({"type": "error", "message": "Room not found"})
|
|
return
|
|
|
|
if len(room.players) >= 6:
|
|
await ctx.websocket.send_json({"type": "error", "message": "Room is full"})
|
|
return
|
|
|
|
if room.game.phase != GamePhase.WAITING:
|
|
await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
|
|
return
|
|
|
|
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
|
|
room.touch()
|
|
ctx.current_room = room
|
|
|
|
await ctx.websocket.send_json({
|
|
"type": "room_joined",
|
|
"room_code": room.code,
|
|
"player_id": ctx.player_id,
|
|
"authenticated": ctx.authenticated_user is not None,
|
|
})
|
|
|
|
await room.broadcast({
|
|
"type": "player_joined",
|
|
"players": room.player_list(),
|
|
})
|
|
|
|
|
|
async def handle_get_cpu_profiles(data: dict, ctx: ConnectionContext, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
await ctx.websocket.send_json({
|
|
"type": "cpu_profiles",
|
|
"profiles": get_all_profiles(),
|
|
})
|
|
|
|
|
|
async def handle_add_cpu(data: dict, ctx: ConnectionContext, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
|
|
room_player = ctx.current_room.get_player(ctx.player_id)
|
|
if not room_player or not room_player.is_host:
|
|
await ctx.websocket.send_json({"type": "error", "message": "Only the host can add CPU players"})
|
|
return
|
|
|
|
if len(ctx.current_room.players) >= 6:
|
|
await ctx.websocket.send_json({"type": "error", "message": "Room is full"})
|
|
return
|
|
|
|
cpu_id = f"cpu_{uuid.uuid4().hex[:8]}"
|
|
profile_name = data.get("profile_name")
|
|
|
|
cpu_player = ctx.current_room.add_cpu_player(cpu_id, profile_name)
|
|
if not cpu_player:
|
|
await ctx.websocket.send_json({"type": "error", "message": "CPU profile not available"})
|
|
return
|
|
|
|
await ctx.current_room.broadcast({
|
|
"type": "player_joined",
|
|
"players": ctx.current_room.player_list(),
|
|
})
|
|
|
|
|
|
async def handle_remove_cpu(data: dict, ctx: ConnectionContext, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
|
|
room_player = ctx.current_room.get_player(ctx.player_id)
|
|
if not room_player or not room_player.is_host:
|
|
return
|
|
|
|
cpu_players = ctx.current_room.get_cpu_players()
|
|
if cpu_players:
|
|
ctx.current_room.remove_player(cpu_players[-1].id)
|
|
await ctx.current_room.broadcast({
|
|
"type": "player_joined",
|
|
"players": ctx.current_room.player_list(),
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Game lifecycle handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
ctx.current_room.touch()
|
|
|
|
room_player = ctx.current_room.get_player(ctx.player_id)
|
|
if not room_player or not room_player.is_host:
|
|
await ctx.websocket.send_json({"type": "error", "message": "Only the host can start the game"})
|
|
return
|
|
|
|
if len(ctx.current_room.players) < 2:
|
|
await ctx.websocket.send_json({"type": "error", "message": "Need at least 2 players"})
|
|
return
|
|
|
|
num_decks = max(1, min(3, data.get("decks", 1)))
|
|
num_rounds = max(1, min(18, data.get("rounds", 1)))
|
|
options = GameOptions.from_client_data(data)
|
|
|
|
async with ctx.current_room.game_lock:
|
|
ctx.current_room.game.start_game(num_decks, num_rounds, options)
|
|
|
|
game_logger = get_logger()
|
|
if game_logger:
|
|
ctx.current_room.game_log_id = game_logger.log_game_start(
|
|
room_code=ctx.current_room.code,
|
|
num_players=len(ctx.current_room.players),
|
|
options=options,
|
|
)
|
|
|
|
# CPU players do their initial flips immediately
|
|
if options.initial_flips > 0:
|
|
for cpu in ctx.current_room.get_cpu_players():
|
|
positions = GolfAI.choose_initial_flips(options.initial_flips)
|
|
ctx.current_room.game.flip_initial_cards(cpu.id, positions)
|
|
|
|
# Send game started to all human players
|
|
for pid, player in ctx.current_room.players.items():
|
|
if player.websocket and not player.is_cpu:
|
|
game_state = ctx.current_room.game.get_state(pid)
|
|
await player.websocket.send_json({
|
|
"type": "game_started",
|
|
"game_state": game_state,
|
|
})
|
|
|
|
check_and_run_cpu_turn(ctx.current_room)
|
|
|
|
|
|
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
ctx.current_room.touch()
|
|
|
|
positions = data.get("positions", [])
|
|
async with ctx.current_room.game_lock:
|
|
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
|
|
await broadcast_game_state(ctx.current_room)
|
|
check_and_run_cpu_turn(ctx.current_room)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Turn action handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
ctx.current_room.touch()
|
|
|
|
source = data.get("source", "deck")
|
|
async with ctx.current_room.game_lock:
|
|
discard_before_draw = ctx.current_room.game.discard_top()
|
|
card = ctx.current_room.game.draw_card(ctx.player_id, source)
|
|
|
|
if card:
|
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
|
reason = f"took {discard_before_draw.rank.value} from discard" if source == "discard" else "drew from deck"
|
|
log_human_action(
|
|
ctx.current_room, player,
|
|
"take_discard" if source == "discard" else "draw_deck",
|
|
card=card, reason=reason,
|
|
)
|
|
|
|
await ctx.websocket.send_json({
|
|
"type": "card_drawn",
|
|
"card": card.to_dict(),
|
|
"source": source,
|
|
})
|
|
|
|
await broadcast_game_state(ctx.current_room)
|
|
|
|
|
|
async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
ctx.current_room.touch()
|
|
|
|
position = data.get("position", 0)
|
|
async with ctx.current_room.game_lock:
|
|
drawn_card = ctx.current_room.game.drawn_card
|
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
|
old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None
|
|
|
|
# Capture old card info BEFORE the swap mutates the player's hand.
|
|
# game.swap_card() overwrites player.cards[position] in place, so if we
|
|
# read it after, we'd get the new card. The client needs the old card data
|
|
# to animate the outgoing card correctly.
|
|
old_was_face_down = old_card and not old_card.face_up if old_card else False
|
|
old_card_data = None
|
|
if old_card and old_was_face_down:
|
|
old_card_data = {
|
|
"rank": old_card.rank.value if old_card.rank else None,
|
|
"suit": old_card.suit.value if old_card.suit else None,
|
|
}
|
|
|
|
discarded = ctx.current_room.game.swap_card(ctx.player_id, position)
|
|
|
|
if discarded:
|
|
if drawn_card and player:
|
|
old_rank = old_card.rank.value if old_card else "?"
|
|
log_human_action(
|
|
ctx.current_room, player, "swap",
|
|
card=drawn_card, position=position,
|
|
reason=f"swapped {drawn_card.rank.value} into position {position}, replaced {old_rank}",
|
|
)
|
|
|
|
await broadcast_game_state(ctx.current_room)
|
|
await asyncio.sleep(1.0)
|
|
check_and_run_cpu_turn(ctx.current_room)
|
|
|
|
|
|
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
ctx.current_room.touch()
|
|
|
|
async with ctx.current_room.game_lock:
|
|
drawn_card = ctx.current_room.game.drawn_card
|
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
|
|
|
if ctx.current_room.game.discard_drawn(ctx.player_id):
|
|
if drawn_card and player:
|
|
log_human_action(
|
|
ctx.current_room, player, "discard",
|
|
card=drawn_card,
|
|
reason=f"discarded {drawn_card.rank.value}",
|
|
)
|
|
|
|
await broadcast_game_state(ctx.current_room)
|
|
|
|
if ctx.current_room.game.flip_on_discard:
|
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
|
has_face_down = player and any(not c.face_up for c in player.cards)
|
|
|
|
if has_face_down:
|
|
await ctx.websocket.send_json({
|
|
"type": "can_flip",
|
|
"optional": ctx.current_room.game.flip_is_optional,
|
|
})
|
|
else:
|
|
await asyncio.sleep(0.5)
|
|
check_and_run_cpu_turn(ctx.current_room)
|
|
else:
|
|
logger.debug("Player discarded, waiting 0.5s before CPU turn")
|
|
await asyncio.sleep(0.5)
|
|
logger.debug("Post-discard delay complete, checking for CPU turn")
|
|
check_and_run_cpu_turn(ctx.current_room)
|
|
|
|
|
|
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
|
|
async with ctx.current_room.game_lock:
|
|
if ctx.current_room.game.cancel_discard_draw(ctx.player_id):
|
|
await broadcast_game_state(ctx.current_room)
|
|
|
|
|
|
async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
ctx.current_room.touch()
|
|
|
|
position = data.get("position", 0)
|
|
async with ctx.current_room.game_lock:
|
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
|
ctx.current_room.game.flip_and_end_turn(ctx.player_id, position)
|
|
|
|
if player and 0 <= position < len(player.cards):
|
|
flipped_card = player.cards[position]
|
|
log_human_action(
|
|
ctx.current_room, player, "flip",
|
|
card=flipped_card, position=position,
|
|
reason=f"flipped card at position {position}",
|
|
)
|
|
|
|
await broadcast_game_state(ctx.current_room)
|
|
check_and_run_cpu_turn(ctx.current_room)
|
|
|
|
|
|
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
ctx.current_room.touch()
|
|
|
|
async with ctx.current_room.game_lock:
|
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
|
if ctx.current_room.game.skip_flip_and_end_turn(ctx.player_id):
|
|
log_human_action(
|
|
ctx.current_room, player, "skip_flip",
|
|
reason="skipped optional flip (endgame mode)",
|
|
)
|
|
|
|
await broadcast_game_state(ctx.current_room)
|
|
check_and_run_cpu_turn(ctx.current_room)
|
|
|
|
|
|
async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
ctx.current_room.touch()
|
|
|
|
position = data.get("position", 0)
|
|
async with ctx.current_room.game_lock:
|
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
|
if ctx.current_room.game.flip_card_as_action(ctx.player_id, position):
|
|
if player and 0 <= position < len(player.cards):
|
|
flipped_card = player.cards[position]
|
|
log_human_action(
|
|
ctx.current_room, player, "flip_as_action",
|
|
card=flipped_card, position=position,
|
|
reason=f"used flip-as-action to reveal position {position}",
|
|
)
|
|
|
|
await broadcast_game_state(ctx.current_room)
|
|
check_and_run_cpu_turn(ctx.current_room)
|
|
|
|
|
|
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
ctx.current_room.touch()
|
|
|
|
async with ctx.current_room.game_lock:
|
|
player = ctx.current_room.game.get_player(ctx.player_id)
|
|
if ctx.current_room.game.knock_early(ctx.player_id):
|
|
if player:
|
|
face_down_count = sum(1 for c in player.cards if not c.face_up)
|
|
log_human_action(
|
|
ctx.current_room, player, "knock_early",
|
|
reason=f"knocked early, revealing {face_down_count} hidden cards",
|
|
)
|
|
|
|
await broadcast_game_state(ctx.current_room)
|
|
check_and_run_cpu_turn(ctx.current_room)
|
|
|
|
|
|
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
ctx.current_room.touch()
|
|
|
|
room_player = ctx.current_room.get_player(ctx.player_id)
|
|
if not room_player or not room_player.is_host:
|
|
return
|
|
|
|
async with ctx.current_room.game_lock:
|
|
if ctx.current_room.game.start_next_round():
|
|
for cpu in ctx.current_room.get_cpu_players():
|
|
positions = GolfAI.choose_initial_flips()
|
|
ctx.current_room.game.flip_initial_cards(cpu.id, positions)
|
|
|
|
for pid, player in ctx.current_room.players.items():
|
|
if player.websocket and not player.is_cpu:
|
|
game_state = ctx.current_room.game.get_state(pid)
|
|
await player.websocket.send_json({
|
|
"type": "round_started",
|
|
"game_state": game_state,
|
|
})
|
|
|
|
check_and_run_cpu_turn(ctx.current_room)
|
|
else:
|
|
await broadcast_game_state(ctx.current_room)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Leave / End handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def handle_leave_room(data: dict, ctx: ConnectionContext, *, handle_player_leave, **kw) -> None:
|
|
if ctx.current_room:
|
|
await handle_player_leave(ctx.current_room, ctx.player_id)
|
|
ctx.current_room = None
|
|
|
|
|
|
async def handle_leave_game(data: dict, ctx: ConnectionContext, *, handle_player_leave, **kw) -> None:
|
|
if ctx.current_room:
|
|
await handle_player_leave(ctx.current_room, ctx.player_id)
|
|
ctx.current_room = None
|
|
|
|
|
|
async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None:
|
|
if not ctx.current_room:
|
|
return
|
|
ctx.current_room.touch()
|
|
|
|
room_player = ctx.current_room.get_player(ctx.player_id)
|
|
if not room_player or not room_player.is_host:
|
|
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
|
|
return
|
|
|
|
# Cancel any running CPU turn task so the game ends immediately
|
|
if ctx.current_room.cpu_turn_task:
|
|
ctx.current_room.cpu_turn_task.cancel()
|
|
try:
|
|
await ctx.current_room.cpu_turn_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
ctx.current_room.cpu_turn_task = None
|
|
|
|
await ctx.current_room.broadcast({
|
|
"type": "game_ended",
|
|
"reason": "Host ended the game",
|
|
})
|
|
|
|
room_code = ctx.current_room.code
|
|
for cpu in list(ctx.current_room.get_cpu_players()):
|
|
ctx.current_room.remove_player(cpu.id)
|
|
cleanup_room_profiles(room_code)
|
|
room_manager.remove_room(room_code)
|
|
ctx.current_room = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Handler dispatch table
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Matchmaking handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def handle_queue_join(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, rating_service=None, **kw) -> None:
|
|
if not matchmaking_service:
|
|
await ctx.websocket.send_json({"type": "error", "message": "Matchmaking not available"})
|
|
return
|
|
|
|
if not ctx.authenticated_user:
|
|
await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to find a game"})
|
|
return
|
|
|
|
# Get player's rating
|
|
rating = 1500.0
|
|
if rating_service:
|
|
try:
|
|
player_rating = await rating_service.get_rating(ctx.auth_user_id)
|
|
rating = player_rating.rating
|
|
except Exception:
|
|
pass
|
|
|
|
status = await matchmaking_service.join_queue(
|
|
user_id=ctx.auth_user_id,
|
|
username=ctx.authenticated_user.username,
|
|
rating=rating,
|
|
websocket=ctx.websocket,
|
|
connection_id=ctx.connection_id,
|
|
)
|
|
|
|
await ctx.websocket.send_json({
|
|
"type": "queue_joined",
|
|
**status,
|
|
})
|
|
|
|
|
|
async def handle_queue_leave(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
|
|
if not matchmaking_service or not ctx.auth_user_id:
|
|
return
|
|
|
|
removed = await matchmaking_service.leave_queue(ctx.auth_user_id)
|
|
await ctx.websocket.send_json({
|
|
"type": "queue_left",
|
|
"was_queued": removed,
|
|
})
|
|
|
|
|
|
async def handle_queue_status(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None:
|
|
if not matchmaking_service or not ctx.auth_user_id:
|
|
await ctx.websocket.send_json({"type": "queue_status", "in_queue": False})
|
|
return
|
|
|
|
status = await matchmaking_service.get_queue_status(ctx.auth_user_id)
|
|
await ctx.websocket.send_json({
|
|
"type": "queue_status",
|
|
**status,
|
|
})
|
|
|
|
|
|
HANDLERS = {
|
|
"create_room": handle_create_room,
|
|
"join_room": handle_join_room,
|
|
"get_cpu_profiles": handle_get_cpu_profiles,
|
|
"add_cpu": handle_add_cpu,
|
|
"remove_cpu": handle_remove_cpu,
|
|
"start_game": handle_start_game,
|
|
"flip_initial": handle_flip_initial,
|
|
"draw": handle_draw,
|
|
"swap": handle_swap,
|
|
"discard": handle_discard,
|
|
"cancel_draw": handle_cancel_draw,
|
|
"flip_card": handle_flip_card,
|
|
"skip_flip": handle_skip_flip,
|
|
"flip_as_action": handle_flip_as_action,
|
|
"knock_early": handle_knock_early,
|
|
"next_round": handle_next_round,
|
|
"leave_room": handle_leave_room,
|
|
"leave_game": handle_leave_game,
|
|
"end_game": handle_end_game,
|
|
"queue_join": handle_queue_join,
|
|
"queue_leave": handle_queue_leave,
|
|
"queue_status": handle_queue_status,
|
|
}
|