From 973c279cbdcbb4c9c9fff6dc392a710525ccbd21 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 10 Jul 2026 01:09:59 +0200 Subject: [PATCH] Boti: pauza pred vedenim kopky, spravne poradie broadcastov po tahu Bot doteraz vedel zahodit prvu kartu novej kopky uz po BOT_MOVE_DELAY_SECONDS (0.8s), zatial co frontend zmetaciu animaciu predoslej kopky prehraval 1650ms -- karta tak "vyletela" uprostred zmetania. _run_bot_turns teraz pri vedeni novej kopky caka TRICK_SWEEP_SECONDS (1.7s), zhodne s SETTLE_MS+ COLLECT_MS v GameTable.tsx. _run_bot_turns tiez posielal player_cards PRED game_status (opacne ako human play_card handler) -- pri prechode do noveho kola tak klient dostal novu ruku skor, nez vedel, ze zacalo nove kolo, a fixne ju hned zobrazil. Poradie broadcastov je teraz zhodne s human handlerom. GameTable/Hand: ruka noveho kola sa zobrazi az po dobehnuti zmetacej animacie poslednej kopky predchadzajuceho kola (displayedHand + freeze efekt), a Hand.tsx rezervuje fixny priestor pre karty aj ked je ruka docasne prazdna, aby layout neposkakoval. tests/test_bots.py: nulovanie TRICK_SWEEP_SECONDS v setUpClass, aby testy zostali rychle. Co-Authored-By: Claude Opus 4.8 --- api/__init__.py | 28 +++++++++++++++---- frontend/src/components/Hand.tsx | 34 ++++++++++++----------- frontend/src/pages/GameTable.tsx | 46 +++++++++++++++++++++++--------- tests/test_bots.py | 2 ++ 4 files changed, 78 insertions(+), 32 deletions(-) diff --git a/api/__init__.py b/api/__init__.py index 8be6673..048b0a5 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -383,6 +383,11 @@ def _load_game_into_memory(info: dict) -> "Game": # 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")) +# Kopka na stole sa po dohrati este chvilu zmieta smerom k vitazovi (SETTLE_MS + +# COLLECT_MS vo frontend/src/pages/GameTable.tsx, spolu 1650ms) -- kym tato +# animacia nedobehne vsetkym hracom, prvy bot na tahu nesmie zahodit kartu do +# novej kopky, inak by mu karta "vyletela" uprostred zmetania predoslej. +TRICK_SWEEP_SECONDS = 1.7 def _kick_bots(gid: str) -> None: @@ -410,10 +415,16 @@ async def _run_bot_turns(gid: str): 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) + delay = BOT_MOVE_DELAY_SECONDS + if rnd.is_guessing_completed() and not rnd.get_last_stash().get_cards() \ + and core.get_previous_stash() is not None: + # Bot vedie novu kopku a este bezi zmetanie tej predoslej. + delay = max(delay, TRICK_SWEEP_SECONDS) + if delay > 0: + await asyncio.sleep(delay) if games.get(gid) is not game: return # hru medzitym niekto ukoncil (end_game) + played_card = False try: if not rnd.is_guessing_completed(): guess = await loop.run_in_executor(None, bot.brain.guess, rnd, seat) @@ -422,15 +433,22 @@ async def _run_bot_turns(gid: str): 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) + played_card = True 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 + # game_status musi ist PRED player_cards -- klient podla neho (novy + # round_number + previous_stash) pozna, ze prave zacalo nove kolo, a + # dovtedy si drzi starych karty na obrazovke (pozri "displayedHand" vo + # frontend/src/pages/GameTable.tsx), kym nedobehne animacia zmetenia + # poslednej kopky. Opacne poradie by novu ruku odhalilo predcasne. await send_game_status(gid) + if played_card: + for player in game.players: + if player.sid: + await send_player_cards(gid, player.order, player.sid) async def send_error_room(gid: str, message: str): diff --git a/frontend/src/components/Hand.tsx b/frontend/src/components/Hand.tsx index 7135ded..05cf7ae 100644 --- a/frontend/src/components/Hand.tsx +++ b/frontend/src/components/Hand.tsx @@ -26,7 +26,6 @@ interface Props { export default function Hand({ hand, myTurn, isPlayPhase, playableKeys, desktop = false }: Props) { const groups = groupedByColor(hand); - if (groups.length === 0) return null; const canPlay = isPlayPhase && myTurn; @@ -52,20 +51,25 @@ export default function Hand({ hand, myTurn, isPlayPhase, playableKeys, desktop
- {desktop ? ( - // Desktop has room — keep cards grouped by suit, wrap if needed. -
- {groups.map(({ color, keys }) => ( -
- {keys.map((key) => ( - - ))} -
- ))} -
- ) : ( - - )} + {/* Fixed height reserves the card row even when the hand is briefly + empty (last card of a round just played, next deal not in yet) -- + otherwise this whole area collapses and the layout jumps. */} +
+ {desktop ? ( + // Desktop has room — keep cards grouped by suit, wrap if needed. +
+ {groups.map(({ color, keys }) => ( +
+ {keys.map((key) => ( + + ))} +
+ ))} +
+ ) : ( + + )} +
); } diff --git a/frontend/src/pages/GameTable.tsx b/frontend/src/pages/GameTable.tsx index b947f29..7f0df88 100644 --- a/frontend/src/pages/GameTable.tsx +++ b/frontend/src/pages/GameTable.tsx @@ -15,7 +15,7 @@ import Standings from '../components/Standings'; import PlayerCircle from '../components/PlayerCircle'; import FaceDownCards from '../components/FaceDownCards'; import GameOver from './GameOver'; -import type { PlayerInfo, StashData } from '../types'; +import type { Hand as HandCards, PlayerInfo, StashData } from '../types'; // A completed trick stays face-up for SETTLE_MS (so the last card visibly joins // the pile), then is swept towards the winner over COLLECT_MS. @@ -72,6 +72,37 @@ export default function GameTable() { }; }, [previousStashKey]); + // A just-completed trick that hasn't been swept away yet always wins the centre + // — even once the winner has already led the next trick. The engine reveals that + // lead card (and, at a round boundary, the next bidding phase) the instant the + // 4th card lands, so without holding the pile here the sweep would be cut off + // after every trick. Reading `previousStash` synchronously (rather than a state + // set in an effect) also means the pile never blinks to empty on the frame the + // 4th card lands — the last card simply joins the three already there, then the + // whole pile is collected before the next trick takes over. + const finishing = previousStashKey !== null && previousStashKey !== dismissedKey; + + // The new round's dealt hand arrives (via `player_cards`) the instant the last + // trick of the previous round is played, but the centre oval is still sweeping + // that trick away — hold an empty hand on screen (the previous round's last card + // really was just played, the engine just never broadcasts that "0 cards" beat on + // its own since it deals the new round in the same step) until the sweep finishes, + // so the new cards don't appear before the previous round has visibly wrapped up. + // Mid-round plays (round number unchanged) still update instantly, since that's + // just the player's own card leaving their hand, not a fresh deal. + const [displayedHand, setDisplayedHand] = useState(hand); + const lastAppliedRoundRef = useRef(gameStatus?.round_number ?? null); + useEffect(() => { + if (!gameStatus) return; + const isNewRound = gameStatus.round_number !== lastAppliedRoundRef.current; + if (isNewRound && finishing) { + setDisplayedHand({}); // last card of the previous round is gone; new deal waits + return; + } + lastAppliedRoundRef.current = gameStatus.round_number; + setDisplayedHand(hand); + }, [hand, gameStatus, finishing]); + if (!gameStatus || !myPlayer) { return

Načítava sa…

; } @@ -95,15 +126,6 @@ export default function GameTable() { const myTurnToPlay = isPlayPhase && active_player === myOrder; const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0; - // A just-completed trick that hasn't been swept away yet always wins the centre - // — even once the winner has already led the next trick. The engine reveals that - // lead card (and, at a round boundary, the next bidding phase) the instant the - // 4th card lands, so without holding the pile here the sweep would be cut off - // after every trick. Reading `previousStash` synchronously (rather than a state - // set in an effect) also means the pile never blinks to empty on the frame the - // 4th card lands — the last card simply joins the three already there, then the - // whole pile is collected before the next trick takes over. - const finishing = previousStashKey !== null && previousStashKey !== dismissedKey; const displayedStash: StashData | null = finishing ? previousStash : activeCards > 0 && active_stash @@ -121,7 +143,7 @@ export default function GameTable() { const canPlayNow = myTurnToPlay && !finishing; const playableKeys = canPlayNow && active_stash - ? computePlayable(hand, active_stash.cards[String(active_stash.first_player)]?.color ?? null) + ? computePlayable(displayedHand, active_stash.cards[String(active_stash.first_player)]?.color ?? null) : undefined; const activePlayerName = displayName(players.find((p) => p.order === active_player)?.name); @@ -244,7 +266,7 @@ export default function GameTable() { ); const handArea = ( - + ); // ── DESKTOP LAYOUT ─────────────────────────────────────────────── diff --git a/tests/test_bots.py b/tests/test_bots.py index d5cdf81..0b285db 100644 --- a/tests/test_bots.py +++ b/tests/test_bots.py @@ -67,6 +67,7 @@ class BotAccountCase(unittest.TestCase): def setUpClass(cls): run(init_db()) api.BOT_MOVE_DELAY_SECONDS = 0 + api.TRICK_SWEEP_SECONDS = 0 def test_username_conventions(self): self.assertTrue(bots.is_bot_username("bot:heuristic-1")) @@ -115,6 +116,7 @@ class BotTurnLoopCase(unittest.TestCase): def setUpClass(cls): run(init_db()) api.BOT_MOVE_DELAY_SECONDS = 0 + api.TRICK_SWEEP_SECONDS = 0 def setUp(self): api.games.clear()