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 <noreply@anthropic.com>
This commit is contained in:
+23
-5
@@ -383,6 +383,11 @@ def _load_game_into_memory(info: dict) -> "Game":
|
|||||||
|
|
||||||
# Pauza medzi tahmi bota, nech ludia stihaju sledovat hru (0 = okamzite).
|
# 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"))
|
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:
|
def _kick_bots(gid: str) -> None:
|
||||||
@@ -410,10 +415,16 @@ async def _run_bot_turns(gid: str):
|
|||||||
bot = game.player_by_order(seat)
|
bot = game.player_by_order(seat)
|
||||||
if bot is None or not bot.is_bot:
|
if bot is None or not bot.is_bot:
|
||||||
return # na tahu je clovek
|
return # na tahu je clovek
|
||||||
if BOT_MOVE_DELAY_SECONDS > 0:
|
delay = BOT_MOVE_DELAY_SECONDS
|
||||||
await asyncio.sleep(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:
|
if games.get(gid) is not game:
|
||||||
return # hru medzitym niekto ukoncil (end_game)
|
return # hru medzitym niekto ukoncil (end_game)
|
||||||
|
played_card = False
|
||||||
try:
|
try:
|
||||||
if not rnd.is_guessing_completed():
|
if not rnd.is_guessing_completed():
|
||||||
guess = await loop.run_in_executor(None, bot.brain.guess, rnd, seat)
|
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)
|
action = await loop.run_in_executor(None, bot.brain.play, rnd, seat)
|
||||||
core.play_card(seat, index_card(action))
|
core.play_card(seat, index_card(action))
|
||||||
await history.record_completed_rounds(gid, core)
|
await history.record_completed_rounds(gid, core)
|
||||||
for player in game.players:
|
played_card = True
|
||||||
if player.sid:
|
|
||||||
await send_player_cards(gid, player.order, player.sid)
|
|
||||||
except BridzikException as exc:
|
except BridzikException as exc:
|
||||||
# Nemalo by nastat (bot hra len legalne tahy) -- nezacykli sa,
|
# Nemalo by nastat (bot hra len legalne tahy) -- nezacykli sa,
|
||||||
# slucku znovu spusti dalsia akcia cloveka.
|
# slucku znovu spusti dalsia akcia cloveka.
|
||||||
await send_error_room(gid, str(exc))
|
await send_error_room(gid, str(exc))
|
||||||
return
|
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)
|
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):
|
async def send_error_room(gid: str, message: str):
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ interface Props {
|
|||||||
|
|
||||||
export default function Hand({ hand, myTurn, isPlayPhase, playableKeys, desktop = false }: Props) {
|
export default function Hand({ hand, myTurn, isPlayPhase, playableKeys, desktop = false }: Props) {
|
||||||
const groups = groupedByColor(hand);
|
const groups = groupedByColor(hand);
|
||||||
if (groups.length === 0) return null;
|
|
||||||
|
|
||||||
const canPlay = isPlayPhase && myTurn;
|
const canPlay = isPlayPhase && myTurn;
|
||||||
|
|
||||||
@@ -52,6 +51,10 @@ export default function Hand({ hand, myTurn, isPlayPhase, playableKeys, desktop
|
|||||||
<div className="h-px flex-1 max-w-[80px] bg-gradient-to-l from-transparent to-gold/20" />
|
<div className="h-px flex-1 max-w-[80px] bg-gradient-to-l from-transparent to-gold/20" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<div className="flex items-end justify-center" style={{ minHeight: desktop ? 100 : 84 }}>
|
||||||
{desktop ? (
|
{desktop ? (
|
||||||
// Desktop has room — keep cards grouped by suit, wrap if needed.
|
// Desktop has room — keep cards grouped by suit, wrap if needed.
|
||||||
<div className="flex flex-wrap gap-3 justify-center items-end">
|
<div className="flex flex-wrap gap-3 justify-center items-end">
|
||||||
@@ -67,6 +70,7 @@ export default function Hand({ hand, myTurn, isPlayPhase, playableKeys, desktop
|
|||||||
<MobileHand groups={groups} cardProps={cardProps} />
|
<MobileHand groups={groups} cardProps={cardProps} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import Standings from '../components/Standings';
|
|||||||
import PlayerCircle from '../components/PlayerCircle';
|
import PlayerCircle from '../components/PlayerCircle';
|
||||||
import FaceDownCards from '../components/FaceDownCards';
|
import FaceDownCards from '../components/FaceDownCards';
|
||||||
import GameOver from './GameOver';
|
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
|
// 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.
|
// the pile), then is swept towards the winner over COLLECT_MS.
|
||||||
@@ -72,6 +72,37 @@ export default function GameTable() {
|
|||||||
};
|
};
|
||||||
}, [previousStashKey]);
|
}, [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<HandCards>(hand);
|
||||||
|
const lastAppliedRoundRef = useRef<number | null>(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) {
|
if (!gameStatus || !myPlayer) {
|
||||||
return <p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa…</p>;
|
return <p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa…</p>;
|
||||||
}
|
}
|
||||||
@@ -95,15 +126,6 @@ export default function GameTable() {
|
|||||||
const myTurnToPlay = isPlayPhase && active_player === myOrder;
|
const myTurnToPlay = isPlayPhase && active_player === myOrder;
|
||||||
|
|
||||||
const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0;
|
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
|
const displayedStash: StashData | null = finishing
|
||||||
? previousStash
|
? previousStash
|
||||||
: activeCards > 0 && active_stash
|
: activeCards > 0 && active_stash
|
||||||
@@ -121,7 +143,7 @@ export default function GameTable() {
|
|||||||
const canPlayNow = myTurnToPlay && !finishing;
|
const canPlayNow = myTurnToPlay && !finishing;
|
||||||
|
|
||||||
const playableKeys = canPlayNow && active_stash
|
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;
|
: undefined;
|
||||||
|
|
||||||
const activePlayerName = displayName(players.find((p) => p.order === active_player)?.name);
|
const activePlayerName = displayName(players.find((p) => p.order === active_player)?.name);
|
||||||
@@ -244,7 +266,7 @@ export default function GameTable() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handArea = (
|
const handArea = (
|
||||||
<Hand hand={hand} myTurn={canPlayNow} isPlayPhase={isPlayPhase} playableKeys={playableKeys} desktop={desktop} />
|
<Hand hand={displayedHand} myTurn={canPlayNow} isPlayPhase={isPlayPhase} playableKeys={playableKeys} desktop={desktop} />
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── DESKTOP LAYOUT ───────────────────────────────────────────────
|
// ── DESKTOP LAYOUT ───────────────────────────────────────────────
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class BotAccountCase(unittest.TestCase):
|
|||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
run(init_db())
|
run(init_db())
|
||||||
api.BOT_MOVE_DELAY_SECONDS = 0
|
api.BOT_MOVE_DELAY_SECONDS = 0
|
||||||
|
api.TRICK_SWEEP_SECONDS = 0
|
||||||
|
|
||||||
def test_username_conventions(self):
|
def test_username_conventions(self):
|
||||||
self.assertTrue(bots.is_bot_username("bot:heuristic-1"))
|
self.assertTrue(bots.is_bot_username("bot:heuristic-1"))
|
||||||
@@ -115,6 +116,7 @@ class BotTurnLoopCase(unittest.TestCase):
|
|||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
run(init_db())
|
run(init_db())
|
||||||
api.BOT_MOVE_DELAY_SECONDS = 0
|
api.BOT_MOVE_DELAY_SECONDS = 0
|
||||||
|
api.TRICK_SWEEP_SECONDS = 0
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
api.games.clear()
|
api.games.clear()
|
||||||
|
|||||||
Reference in New Issue
Block a user