Frontend: pridavanie botov v lobby

Hostitel prida na volne sedadlo bota (+ Bot = heuristika, + AI bot =
natrenovana siet) alebo bota odoberie; boti maju vlastnu ikonu a flag
is_bot v rosteroch. Surove ucty bot:<kind>-<n> sa vsade zobrazuju cez
displayName ako "Bot 2" / "AI bot 1" (stol, kopka, tabulky, historia).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tim
2026-07-07 18:51:14 +02:00
co-authored by Claude Fable 5
parent 23de3ae3ac
commit b1010ae008
9 changed files with 68 additions and 11 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { PlayerInfo } from '../types'; import type { PlayerInfo } from '../types';
import { computeTotal } from '../lib/standings'; import { computeTotal } from '../lib/standings';
import { displayName } from '../lib/names';
interface Props { interface Props {
standings: number[][][]; standings: number[][][];
@@ -62,7 +63,7 @@ export default function Standings({ standings, guesses = [], players, myOrder, d
}`} }`}
style={{ fontSize: fz.head }} style={{ fontSize: fz.head }}
> >
{p.name} {displayName(p.name)}
</div> </div>
))} ))}
</div> </div>
+2 -1
View File
@@ -1,5 +1,6 @@
import type { PlayerInfo, StashData } from '../types'; import type { PlayerInfo, StashData } from '../types';
import CardView from './CardView'; import CardView from './CardView';
import { displayName } from '../lib/names';
interface Props { interface Props {
stash: StashData | null; stash: StashData | null;
@@ -18,7 +19,7 @@ export default function Trick({ stash, players, myOrder }: Props) {
: []; : [];
const nameFor = (order: number) => const nameFor = (order: number) =>
players.find((p) => p.order === order)?.name ?? ''; displayName(players.find((p) => p.order === order)?.name);
const overlap = -16; const overlap = -16;
const slotH = 80; const slotH = 80;
+22
View File
@@ -0,0 +1,22 @@
/** Display helpers for player names. Bot accounts follow the server-side
* convention "bot:<kind>-<n>" (see api/bots.py) — render them as a short
* friendly label instead of the raw username. */
const BOT_PREFIX = 'bot:';
export function isBotName(name?: string | null): boolean {
return !!name && name.startsWith(BOT_PREFIX);
}
/** "bot:heuristic-2" -> "Bot 2", "bot:neural-1" -> "AI bot 1"; other kinds
* keep a suffix ("Bot 1 (random)"); non-bot names pass through unchanged. */
export function displayName(name?: string | null): string {
if (!name || !isBotName(name)) return name ?? '';
const body = name.slice(BOT_PREFIX.length);
const dash = body.lastIndexOf('-');
const kind = dash > 0 ? body.slice(0, dash) : body;
const num = dash > 0 ? body.slice(dash + 1) : '';
if (kind === 'neural') return num ? `AI bot ${num}` : 'AI bot';
const label = num ? `Bot ${num}` : 'Bot';
return kind === 'heuristic' ? label : `${label} (${kind})`;
}
+4
View File
@@ -50,6 +50,10 @@ export const emit = {
// Reopen a prematurely-ended game from history back into the lobby. // Reopen a prematurely-ended game from history back into the lobby.
restoreGame: (gid: string) => socket.emit('restore_game', gid), restoreGame: (gid: string) => socket.emit('restore_game', gid),
leaveGame: () => socket.emit('leave_game'), leaveGame: () => socket.emit('leave_game'),
// Bots: host-only, before the game starts (seat picked by the server).
// kind: 'heuristic' (default) | 'neural' (trained net) | 'random'
addBot: (gid: string, kind: string = 'heuristic') => socket.emit('add_bot', gid, kind),
removeBot: (gid: string, order: number) => socket.emit('remove_bot', gid, order),
endGame: (gid: string) => socket.emit('end_game', gid), endGame: (gid: string) => socket.emit('end_game', gid),
startGame: (gid: string) => socket.emit('start_game', gid), startGame: (gid: string) => socket.emit('start_game', gid),
reconnectToGame: (gid: string, token: string) => socket.emit('reconnect_to_game', gid, token), reconnectToGame: (gid: string, token: string) => socket.emit('reconnect_to_game', gid, token),
+2 -1
View File
@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router-dom';
import type { PlayerInfo } from '../types'; import type { PlayerInfo } from '../types';
import { computeTotal } from '../lib/standings'; import { computeTotal } from '../lib/standings';
import { leaveGame } from '../lib/leaveGame'; import { leaveGame } from '../lib/leaveGame';
import { displayName } from '../lib/names';
interface Props { interface Props {
players: PlayerInfo[]; players: PlayerInfo[];
@@ -30,7 +31,7 @@ export default function GameOver({ players, standings }: Props) {
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-2xl w-8">{medals[i]}</span> <span className="text-2xl w-8">{medals[i]}</span>
<span className="font-serif text-green-score">{p.name}</span> <span className="font-serif text-green-score">{displayName(p.name)}</span>
</div> </div>
<span className={`font-serif text-xl ${i === 0 ? 'text-gold-bright' : 'text-gold'}`}> <span className={`font-serif text-xl ${i === 0 ? 'text-gold-bright' : 'text-gold'}`}>
{p.total} {p.total}
+5 -4
View File
@@ -5,6 +5,7 @@ import { emit } from '../lib/socket';
import { leaveGame } from '../lib/leaveGame'; import { leaveGame } from '../lib/leaveGame';
import { computePlayable, stashWinner } from '../lib/gameRules'; import { computePlayable, stashWinner } from '../lib/gameRules';
import { computeTotal } from '../lib/standings'; import { computeTotal } from '../lib/standings';
import { displayName } from '../lib/names';
import { useIsDesktop } from '../lib/useIsDesktop'; import { useIsDesktop } from '../lib/useIsDesktop';
import { useFitScale } from '../lib/useFitScale'; import { useFitScale } from '../lib/useFitScale';
import Hand from '../components/Hand'; import Hand from '../components/Hand';
@@ -123,7 +124,7 @@ export default function GameTable() {
? computePlayable(hand, active_stash.cards[String(active_stash.first_player)]?.color ?? null) ? computePlayable(hand, active_stash.cards[String(active_stash.first_player)]?.color ?? null)
: undefined; : undefined;
const activePlayerName = players.find((p) => p.order === active_player)?.name ?? ''; const activePlayerName = displayName(players.find((p) => p.order === active_player)?.name);
// Seat mapping relative to "Ty": left / across / right. // Seat mapping relative to "Ty": left / across / right.
const seat = (offset: number): PlayerInfo | undefined => const seat = (offset: number): PlayerInfo | undefined =>
@@ -183,7 +184,7 @@ export default function GameTable() {
{opponents.map((p) => ( {opponents.map((p) => (
<div key={p.order} className="text-center"> <div key={p.order} className="text-center">
<div className="uppercase tracking-[.1em] text-green-dim mb-0.5" style={{ fontSize: 11 }}> <div className="uppercase tracking-[.1em] text-green-dim mb-0.5" style={{ fontSize: 11 }}>
{p.name} {displayName(p.name)}
</div> </div>
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? 16 : 20 }}> <div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? 16 : 20 }}>
{computeTotal(standings, p.order)} {computeTotal(standings, p.order)}
@@ -224,14 +225,14 @@ export default function GameTable() {
const topSeat = ( const topSeat = (
<div className="flex flex-col items-center gap-1.5"> <div className="flex flex-col items-center gap-1.5">
<PlayerCircle name={topP?.name ?? '—'} {...seatProps(topP?.order)} size={desktop ? 64 : 52} /> <PlayerCircle name={displayName(topP?.name) || '—'} {...seatProps(topP?.order)} size={desktop ? 64 : 52} />
<FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} /> <FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} />
</div> </div>
); );
const sideSeat = (p?: PlayerInfo) => ( const sideSeat = (p?: PlayerInfo) => (
<div className="flex flex-col items-center gap-1.5"> <div className="flex flex-col items-center gap-1.5">
<PlayerCircle name={p?.name ?? '—'} {...seatProps(p?.order)} size={desktop ? 60 : 48} /> <PlayerCircle name={displayName(p?.name) || '—'} {...seatProps(p?.order)} size={desktop ? 60 : 48} />
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} /> <FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
</div> </div>
); );
+3 -2
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore'; import { useGameStore } from '../store/gameStore';
import { emit, socket } from '../lib/socket'; import { emit, socket } from '../lib/socket';
import { useIsDesktop } from '../lib/useIsDesktop'; import { useIsDesktop } from '../lib/useIsDesktop';
import { displayName } from '../lib/names';
import type { GameDetail, GameDetailRound } from '../types'; import type { GameDetail, GameDetailRound } from '../types';
function fmtDate(iso: string | null): string { function fmtDate(iso: string | null): string {
@@ -62,7 +63,7 @@ export default function History() {
className="flex-1 min-w-0 text-left px-4 py-3 hover:bg-white/[.02] transition-colors" className="flex-1 min-w-0 text-left px-4 py-3 hover:bg-white/[.02] transition-colors"
> >
<p className="font-serif text-green-score truncate">{g.name || 'Hra'}</p> <p className="font-serif text-green-score truncate">{g.name || 'Hra'}</p>
<p className="text-xs text-green-dim mt-1 truncate">{g.players.join(', ')}</p> <p className="text-xs text-green-dim mt-1 truncate">{g.players.map((n) => displayName(n)).join(', ')}</p>
<p className="text-xs text-[#7a7058] mt-0.5"> <p className="text-xs text-[#7a7058] mt-0.5">
{fmtDate(g.created_at)} · {g.completed ? 'dohraná' : 'predčasne ukončená'} {fmtDate(g.created_at)} · {g.completed ? 'dohraná' : 'predčasne ukončená'}
</p> </p>
@@ -158,7 +159,7 @@ function GameDetailView({ detail, onBack }: { detail: GameDetail; onBack: () =>
}} }}
> >
<span className="uppercase text-gold" style={{ letterSpacing: '.06em', fontSize: 11 }}> <span className="uppercase text-gold" style={{ letterSpacing: '.06em', fontSize: 11 }}>
{p.username} {displayName(p.username)}
</span> </span>
<span className="font-serif text-gold-dim" style={{ fontWeight: 700, fontSize: 16 }}> <span className="font-serif text-gold-dim" style={{ fontWeight: 700, fontSize: 16 }}>
{totals[c]} {totals[c]}
+27 -2
View File
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { useGameStore } from '../store/gameStore'; import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket'; import { emit } from '../lib/socket';
import { leaveGame } from '../lib/leaveGame'; import { leaveGame } from '../lib/leaveGame';
import { displayName } from '../lib/names';
export default function Lobby() { export default function Lobby() {
const { gid } = useParams<{ gid: string }>(); const { gid } = useParams<{ gid: string }>();
@@ -65,11 +66,35 @@ export default function Lobby() {
return ( return (
<div key={order} className="flex items-center gap-3"> <div key={order} className="flex items-center gap-3">
<span className={`text-lg ${p ? 'text-gold' : 'text-[#7a7058]'}`}> <span className={`text-lg ${p ? 'text-gold' : 'text-[#7a7058]'}`}>
{p ? '✦' : '○'} {p ? (p.is_bot ? '⚙' : '✦') : '○'}
</span> </span>
<span className={p ? 'font-serif text-green-score' : 'text-green-dim italic'}> <span className={p ? 'font-serif text-green-score' : 'text-green-dim italic'}>
{p ? `${p.name}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Čaká sa…'} {p ? `${displayName(p.name)}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Čaká sa…'}
</span> </span>
{isHost && p?.is_bot && (
<button
onClick={() => gid && emit.removeBot(gid, order)}
className="ml-auto text-xs text-green-dim hover:text-gold"
>
Odobrať
</button>
)}
{isHost && !p && (
<span className="ml-auto flex gap-2">
<button
onClick={() => gid && emit.addBot(gid)}
className="px-2 py-0.5 rounded-lg text-xs border border-gold/30 text-gold hover:bg-gold hover:text-table transition-colors"
>
+ Bot
</button>
<button
onClick={() => gid && emit.addBot(gid, 'neural')}
className="px-2 py-0.5 rounded-lg text-xs border border-gold/30 text-gold hover:bg-gold hover:text-table transition-colors"
>
+ AI bot
</button>
</span>
)}
</div> </div>
); );
})} })}
+1
View File
@@ -11,6 +11,7 @@ export interface PlayerInfo {
name: string; name: string;
connected: boolean; connected: boolean;
player_id?: number; player_id?: number;
is_bot?: boolean;
} }
export interface MyPlayer { export interface MyPlayer {