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:
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import type { PlayerInfo } from '../types';
|
||||
import { computeTotal } from '../lib/standings';
|
||||
import { displayName } from '../lib/names';
|
||||
|
||||
interface Props {
|
||||
standings: number[][][];
|
||||
@@ -62,7 +63,7 @@ export default function Standings({ standings, guesses = [], players, myOrder, d
|
||||
}`}
|
||||
style={{ fontSize: fz.head }}
|
||||
>
|
||||
{p.name}
|
||||
{displayName(p.name)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PlayerInfo, StashData } from '../types';
|
||||
import CardView from './CardView';
|
||||
import { displayName } from '../lib/names';
|
||||
|
||||
interface Props {
|
||||
stash: StashData | null;
|
||||
@@ -18,7 +19,7 @@ export default function Trick({ stash, players, myOrder }: Props) {
|
||||
: [];
|
||||
|
||||
const nameFor = (order: number) =>
|
||||
players.find((p) => p.order === order)?.name ?? '';
|
||||
displayName(players.find((p) => p.order === order)?.name);
|
||||
|
||||
const overlap = -16;
|
||||
const slotH = 80;
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -50,6 +50,10 @@ export const emit = {
|
||||
// Reopen a prematurely-ended game from history back into the lobby.
|
||||
restoreGame: (gid: string) => socket.emit('restore_game', gid),
|
||||
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),
|
||||
startGame: (gid: string) => socket.emit('start_game', gid),
|
||||
reconnectToGame: (gid: string, token: string) => socket.emit('reconnect_to_game', gid, token),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import type { PlayerInfo } from '../types';
|
||||
import { computeTotal } from '../lib/standings';
|
||||
import { leaveGame } from '../lib/leaveGame';
|
||||
import { displayName } from '../lib/names';
|
||||
|
||||
interface Props {
|
||||
players: PlayerInfo[];
|
||||
@@ -30,7 +31,7 @@ export default function GameOver({ players, standings }: Props) {
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<span className={`font-serif text-xl ${i === 0 ? 'text-gold-bright' : 'text-gold'}`}>
|
||||
{p.total}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { emit } from '../lib/socket';
|
||||
import { leaveGame } from '../lib/leaveGame';
|
||||
import { computePlayable, stashWinner } from '../lib/gameRules';
|
||||
import { computeTotal } from '../lib/standings';
|
||||
import { displayName } from '../lib/names';
|
||||
import { useIsDesktop } from '../lib/useIsDesktop';
|
||||
import { useFitScale } from '../lib/useFitScale';
|
||||
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)
|
||||
: 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.
|
||||
const seat = (offset: number): PlayerInfo | undefined =>
|
||||
@@ -183,7 +184,7 @@ export default function GameTable() {
|
||||
{opponents.map((p) => (
|
||||
<div key={p.order} className="text-center">
|
||||
<div className="uppercase tracking-[.1em] text-green-dim mb-0.5" style={{ fontSize: 11 }}>
|
||||
{p.name}
|
||||
{displayName(p.name)}
|
||||
</div>
|
||||
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? 16 : 20 }}>
|
||||
{computeTotal(standings, p.order)}
|
||||
@@ -224,14 +225,14 @@ export default function GameTable() {
|
||||
|
||||
const topSeat = (
|
||||
<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} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const sideSeat = (p?: PlayerInfo) => (
|
||||
<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} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit, socket } from '../lib/socket';
|
||||
import { useIsDesktop } from '../lib/useIsDesktop';
|
||||
import { displayName } from '../lib/names';
|
||||
import type { GameDetail, GameDetailRound } from '../types';
|
||||
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{fmtDate(g.created_at)} · {g.completed ? 'dohraná' : 'predčasne ukončená'}
|
||||
</p>
|
||||
@@ -158,7 +159,7 @@ function GameDetailView({ detail, onBack }: { detail: GameDetail; onBack: () =>
|
||||
}}
|
||||
>
|
||||
<span className="uppercase text-gold" style={{ letterSpacing: '.06em', fontSize: 11 }}>
|
||||
{p.username}
|
||||
{displayName(p.username)}
|
||||
</span>
|
||||
<span className="font-serif text-gold-dim" style={{ fontWeight: 700, fontSize: 16 }}>
|
||||
{totals[c]}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit } from '../lib/socket';
|
||||
import { leaveGame } from '../lib/leaveGame';
|
||||
import { displayName } from '../lib/names';
|
||||
|
||||
export default function Lobby() {
|
||||
const { gid } = useParams<{ gid: string }>();
|
||||
@@ -65,11 +66,35 @@ export default function Lobby() {
|
||||
return (
|
||||
<div key={order} className="flex items-center gap-3">
|
||||
<span className={`text-lg ${p ? 'text-gold' : 'text-[#7a7058]'}`}>
|
||||
{p ? '✦' : '○'}
|
||||
{p ? (p.is_bot ? '⚙' : '✦') : '○'}
|
||||
</span>
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface PlayerInfo {
|
||||
name: string;
|
||||
connected: boolean;
|
||||
player_id?: number;
|
||||
is_bot?: boolean;
|
||||
}
|
||||
|
||||
export interface MyPlayer {
|
||||
|
||||
Reference in New Issue
Block a user