diff --git a/client/main.ts b/client/main.ts index 7e91fec0a..081d98c05 100644 --- a/client/main.ts +++ b/client/main.ts @@ -18,6 +18,7 @@ import { analysisView, embedView } from './analysis'; import { puzzleView } from './puzzle'; import { profileView } from './profile'; import { tournamentView } from './tournament'; +import { simulView } from './simul/simul'; import { calendarView } from './calendar'; import { pasteView } from './paste'; import { statsView } from './stats'; @@ -69,6 +70,7 @@ function initModel(el: HTMLElement) { gameId : el.getAttribute("data-gameid") ?? "", tournamentId : el.getAttribute("data-tournamentid") ?? "", tournamentname : el.getAttribute("data-tournamentname") ?? "", + simulId : el.getAttribute("data-simulid") ?? "", tournamentcreator: el.getAttribute("data-tournamentcreator") ?? "", inviter : el.getAttribute("data-inviter") ?? "", ply : parseInt(""+el.getAttribute("data-ply")), @@ -152,6 +154,8 @@ export function view(el: HTMLElement, model: PyChessModel): VNode { return h('div#main-wrap', editorView(model)); case 'tournament': return h('div#main-wrap', [h('main.tour', tournamentView(model))]); + case 'simul': + return h('div#main-wrap', [h('main.simul', simulView(model))]); case 'calendar': return h('div#calendar', calendarView()); case 'games': diff --git a/client/simul/simul.ts b/client/simul/simul.ts new file mode 100644 index 000000000..36d0a67ed --- /dev/null +++ b/client/simul/simul.ts @@ -0,0 +1,408 @@ +import { h, VNode } from 'snabbdom'; +import { Chessground } from 'chessgroundx'; +import { Api } from "chessgroundx/api"; +import { VARIANTS } from '../variants'; + +import { PyChessModel } from '../types'; +import { _ } from '../i18n'; +import { patch } from '../document'; +import { chatView, ChatController } from '../chat'; +import { newWebsocket } from "@/socket/webSocketUtils"; + +interface SimulPlayer { + name: string; + rating: number; + title: string; +} + +interface SimulGame { + gameId: string; + wplayer: string; + bplayer: string; + variant: string; + fen: string; + rated: boolean; + base: number; + inc: number; + byo: number; + result?: string; // Game result when finished (e.g., "1-0", "0-1", "1/2-1/2", or undefined when ongoing) +} + +interface MsgSimulUserConnected { + type: string; + simulId: string; + players: SimulPlayer[]; + pendingPlayers: SimulPlayer[]; + createdBy: string; +} + +export class SimulController implements ChatController { + sock; + anon: boolean; + simulId: string; + players: SimulPlayer[] = []; + pendingPlayers: SimulPlayer[] = []; + createdBy: string; + model: PyChessModel; + games: SimulGame[] = []; + activeGameId: string | null = null; + chessgrounds: { [gameId: string]: Api } = {}; + + constructor(el: HTMLElement, model: PyChessModel) { + console.log("SimulController constructor", el, model); + this.anon = model["anon"] !== undefined && model["anon"] !== ""; + this.simulId = model["simulId"] || ""; + this.model = model; + this.players = model["players"] || []; + this.pendingPlayers = model["pendingPlayers"] || []; + this.createdBy = model["createdBy"] || ""; + + const onOpen = () => { + this.doSend({ type: "simul_user_connected", username: model["username"], simulId: this.simulId }); + } + + this.sock = newWebsocket('wss'); + this.sock.onopen = () => onOpen(); + this.sock.onmessage = (e: MessageEvent) => this.onMessage(e); + + this.redraw(); + patch(document.getElementById('lobbychat') as HTMLElement, chatView(this, "lobbychat")); + } + + doSend(message: object) { + this.sock.send(JSON.stringify(message)); + } + + onMessage(evt: MessageEvent) { + console.log("<+++ simul onMessage():", evt.data); + if (evt.data === '/n') return; + const msg = JSON.parse(evt.data); + switch (msg.type) { + case "simul_user_connected": + this.onMsgSimulUserConnected(msg); + break; + case "new_game": + this.onMsgNewGame(msg); + break; + case "player_joined": + this.onMsgPlayerJoined(msg); + break; + case "player_approved": + this.onMsgPlayerApproved(msg); + break; + case "player_denied": + this.onMsgPlayerDenied(msg); + break; + } + } + + onMsgSimulUserConnected(msg: MsgSimulUserConnected) { + this.players = msg.players; + this.pendingPlayers = msg.pendingPlayers; + this.createdBy = msg.createdBy; + this.redraw(); + } + + onMsgNewGame(msg: SimulGame) { + this.games.push(msg); + if (this.activeGameId === null) { + this.activeGameId = msg.gameId; + } + this.redraw(); + } + + onMsgPlayerJoined(msg: { player: SimulPlayer }) { + this.pendingPlayers.push(msg.player); + this.redraw(); + } + + onMsgPlayerApproved(msg: { username: string }) { + const player = this.pendingPlayers.find(p => p.name === msg.username); + if (player) { + this.pendingPlayers = this.pendingPlayers.filter(p => p.name !== msg.username); + this.players.push(player); + this.redraw(); + } + } + + onMsgPlayerDenied(msg: { username: string }) { + this.pendingPlayers = this.pendingPlayers.filter(p => p.name !== msg.username); + this.players = this.players.filter(p => p.name !== msg.username); + this.redraw(); + } + + setActiveGame(gameId: string) { + this.activeGameId = gameId; + this.redraw(); + } + + redraw() { + patch(document.getElementById('simul-view') as HTMLElement, this.render()); + } + + approve(username: string) { + this.doSend({ type: "approve_player", simulId: this.simulId, username: username }); + } + + deny(username: string) { + this.doSend({ type: "deny_player", simulId: this.simulId, username: username }); + } + + startSimul() { + this.doSend({ type: "start_simul", simulId: this.simulId }); + } + + joinSimul() { + this.doSend({ type: "join", simulId: this.simulId }); + } + + render() { + const isHost = this.model.username === this.createdBy; + const isSimulStarted = this.games.length > 0; + const isSimulFinished = isSimulStarted && this.games.every(game => game.result); // Assuming game has a result field when finished + + const simulStatus = isSimulFinished ? 'Finished' : isSimulStarted ? 'Playing now' : 'Waiting for players'; + + // Create header with simul info + const simulHeader = h('div.simul-header', [ + h('h1.simul-title', [ + h('span', `${this.model["name"] || 'Simul'} `), + h('span.simul-status', { class: { 'status-finished': isSimulFinished, 'status-started': isSimulStarted, 'status-waiting': !isSimulStarted } }, `(${simulStatus})`) + ]), + h('div.simul-info', [ + h('div.variant-info', `${this.model["variant"] || 'Standard'} • ${this.formatTimeControl()}`), + h('div.created-by', `By ${this.createdBy}`) + ]) + ]); + + const startButton = isHost && !isSimulStarted + ? h('button.button', { on: { click: () => this.startSimul() } }, 'Start Simul') + : h('div'); + + const joinButton = (!isHost && !isSimulStarted && !this.players.find(p => p.name === this.model.username) && !this.pendingPlayers.find(p => p.name === this.model.username)) + ? h('button.button', { on: { click: () => this.joinSimul() } }, 'Join Simul') + : h('div'); + + // Main content area - different depending on if it's host vs player and if simul is started + const mainContent = isSimulStarted + ? h('div.simul-ongoing', [ + isHost + ? h('div.simul-host-view', [ + h('div.simul-boards-container', [ + this.renderMiniBoards(), + this.renderBoardControls() + ]) + ]) + : h('div.simul-player-view', [ + // Player sees their own game board here when simul is ongoing + h('div', 'Your game board would appear here when simul starts') + ]) + ]) + : h('div.simul-waiting', [ + // Waiting for approval / game start view + h('div.simul-players-section', [ + h('h2', 'Participants'), + h('div.players-grid', [ + h('div.pending-players', [ + h('h3', `Pending Players (${this.pendingPlayers.length})`), + this.pendingPlayers.length > 0 + ? h('ul', this.pendingPlayers.map(p => h('li', [ + h('span.player-info', [ + p.title ? h('span.title', p.title) : null, + h('span.name', p.name), + h('span.rating', `(${p.rating})`) + ]), + isHost ? h('div.player-actions', [ + h('button.button.btn-approve', { on: { click: () => this.approve(p.name) } }, '✓'), + h('button.button.btn-deny', { on: { click: () => this.deny(p.name) } }, 'X') + ]) : null + ]))) + : h('p.empty', 'No pending players') + ]), + h('div.approved-players', [ + h('h3', `Approved Players (${this.players.length})`), + this.players.length > 0 + ? h('ul', this.players.map(p => h('li', [ + h('span.player-info', [ + p.title ? h('span.title', p.title) : null, + h('span.name', p.name), + h('span.rating', `(${p.rating})`) + ]), + (isHost && p.name !== this.model.username) ? h('div.player-actions', [ + h('button.button.btn-deny', { on: { click: () => this.deny(p.name) } }, 'Remove') + ]) : null + ]))) + : h('p.empty', 'No approved players yet') + ]) + ]) + ]) + ]); + + return h('div#simul-view', [ + simulHeader, + h('div.simul-content', [ + // Side panel (similar to tournament structure) + h('div', { style: { 'grid-area': 'side' } }, [ + // Would contain simul info, player list, etc. + h('div.box.pad', [ + h('h2', 'About this Simul'), + h('p', `A simul exhibition where ${this.createdBy} plays against multiple opponents simultaneously.`), + h('p', `Time control: ${this.formatTimeControl()}`), + h('p', `Variant: ${this.model["variant"] || 'Standard'}`) + ]) + ]), + + // Main content area + h('div.simul-main', [ + startButton, + joinButton, + mainContent, + ]), + + // Table panel (for game list/standings) + h('div', { style: { 'grid-area': 'table' } }, [ + h('div.box.pad', [ + h('h2', 'Games'), + h('div.game-list', [ + this.games.length > 0 + ? h('ul', this.games.map(game => h('li', `${game.wplayer} vs ${game.bplayer}`))) + : h('p', 'No games yet') + ]) + ]) + ]), + + // Under chat panel + h('div', { style: { 'grid-area': 'uchat' } }, [ + h('div#lobbychat.chat-container') + ]), + + // Players panel + h('div', { style: { 'grid-area': 'players' } }, [ + h('div.box.pad', [ + h('h2', 'Players'), + h('p', `Total: ${this.players.length + this.pendingPlayers.length}`) + ]) + ]) + ]) + ]); + } + + formatTimeControl(): string { + const base = this.model["base"] || 0; + const inc = this.model["inc"] || 0; + if (base === 0 && inc === 0) return "Untimed"; + + const baseMinutes = base > 0 ? `${base}m` : ''; + const incSeconds = inc > 0 ? `+${inc}s` : ''; + return `${baseMinutes}${incSeconds}`; + } + + renderBoardControls() { + return h('div.simul-board-controls', [ + h('div.navigation', [ + h('button.button.nav-btn', { + on: { click: () => this.navigateToGame('prev') }, + attrs: { title: 'Previous game' } + }, '‹'), + h('span.game-info', this.getActiveGameInfo()), + h('button.button.nav-btn', { + on: { click: () => this.navigateToGame('next') }, + attrs: { title: 'Next game' } + }, '›'), + h('button.button.nav-btn.auto-skip', { + on: { click: () => this.toggleAutoSkip() } + }, 'Auto-skip') + ]) + ]); + } + + getActiveGameInfo(): string { + if (!this.activeGameId) return 'No active game'; + const game = this.games.find(g => g.gameId === this.activeGameId); + if (!game) return 'Unknown game'; + return `${game.wplayer} vs ${game.bplayer}`; + } + + navigateToGame(direction: 'next' | 'prev') { + if (this.games.length === 0) return; + + const currentIndex = this.games.findIndex(g => g.gameId === this.activeGameId); + let targetIndex: number; + + if (direction === 'next') { + targetIndex = (currentIndex + 1) % this.games.length; + } else { + targetIndex = (currentIndex - 1 + this.games.length) % this.games.length; + } + + if (targetIndex >= 0 && targetIndex < this.games.length) { + this.setActiveGame(this.games[targetIndex].gameId); + } + } + + toggleAutoSkip() { + // Implement auto-skip functionality + console.log("Auto-skip toggled"); + } + + renderMiniBoards() { + if (this.games.length === 0) { + return h('div.no-games', 'No games created yet'); + } + + return h('div.mini-boards', this.games.map(game => { + const variant = VARIANTS[game.variant]; + const isActive = game.gameId === this.activeGameId; + const isFinished = !!game.result; + + return h(`div.mini-board`, { + on: { click: () => this.setActiveGame(game.gameId) }, + class: { + active: isActive, + finished: isFinished + } + }, [ + h(`div.cg-wrap.${variant.board.cg}`, { + hook: { + insert: vnode => { + const cg = Chessground(vnode.elm as HTMLElement, { + fen: game.fen, + viewOnly: true, + coordinates: false, + }); + this.chessgrounds[game.gameId] = cg; + }, + destroy: vnode => { + // Clean up chessground instance when element is removed + // Find the game associated with this chessground + const cgElement = vnode.elm as HTMLElement; + if (cgElement) { + // Find which game this chessground belongs to by checking the class + const game = this.games.find(g => + cgElement.classList.contains(VARIANTS[g.variant].board.cg) + ); + if (game && this.chessgrounds[game.gameId]) { + this.chessgrounds[game.gameId].destroy(); + delete this.chessgrounds[game.gameId]; + } + } + } + } + }), + h('div.game-info', [ + h('div.players', `${game.wplayer} vs ${game.bplayer}`), + isFinished && h('div.result', game.result), + !isFinished && h('div.status', 'Ongoing') + ]) + ]); + })); + } +} + +export function simulView(model: PyChessModel): VNode[] { + return [ + h('div#simul-view', { hook: { insert: vnode => new SimulController(vnode.elm as HTMLElement, model) } }, [ + // initial content, will be replaced by redraw + ]) + ]; +} diff --git a/client/types.ts b/client/types.ts index 4b3ddf396..240bbbee3 100644 --- a/client/types.ts +++ b/client/types.ts @@ -9,6 +9,24 @@ export type JSONArray = JSONValue[]; export type BugBoardName = 'a' | 'b'; export type BoardName = '' | BugBoardName; +export interface SimulPlayer { + name: string; + rating: number; + title: string; +} + +export interface SimulGame { + gameId: string; + wplayer: string; + bplayer: string; + variant: string; + fen: string; + rated: boolean; + base: number; + inc: number; + byo: number; +} + export type PyChessModel = { ffish: FairyStockfish; username: string; @@ -69,4 +87,11 @@ export type PyChessModel = { oauth_provider: string; oauth_username: string; } | null; + + // Simul-specific properties + simulId?: string; + players?: SimulPlayer[]; + pendingPlayers?: SimulPlayer[]; + createdBy?: string; + name?: string; }; diff --git a/server/const.py b/server/const.py index 61c80fa63..f863d00c1 100644 --- a/server/const.py +++ b/server/const.py @@ -86,6 +86,7 @@ class TPairing(IntEnum): ARENA = 0 RR = 1 SWISS = 2 + SIMUL = 3 # translations @@ -274,6 +275,7 @@ def _(message): 0: _("Arena"), 1: _("Round-Robin"), 2: _("Swiss"), + 3: _("Simul"), } TRANSLATED_FREQUENCY_NAMES = { diff --git a/server/game.py b/server/game.py index cc81be8ce..cf00ca1b3 100644 --- a/server/game.py +++ b/server/game.py @@ -72,6 +72,7 @@ def __init__( corr=False, create=True, tournamentId=None, + simulId=None, new_960_fen_needed_for_rematch=False, ): self.app_state = app_state @@ -94,6 +95,7 @@ def __init__( self.inc = inc self.level = level if level is not None else 0 self.tournamentId = tournamentId + self.simulId = simulId self.chess960 = chess960 self.corr = corr self.create = create @@ -404,6 +406,8 @@ async def play_move(self, move, clocks=None, ply=None): await self.save_game() if self.corr: await opp_player.notify_game_end(self) + if self.simulId is not None: + await self.app_state.simuls[self.simulId].game_update(self) else: await self.save_move(move) diff --git a/server/pychess_global_app_state.py b/server/pychess_global_app_state.py index 95c36eea5..d359181e0 100644 --- a/server/pychess_global_app_state.py +++ b/server/pychess_global_app_state.py @@ -58,6 +58,7 @@ DEV, static_url, ) +from simul.simul import Simul from tournament.tournament import Tournament from tournament.tournaments import ( translated_tournament_name, @@ -102,6 +103,7 @@ def __init__(self, app: web.Application): self.tourneynames: dict[str, dict] = {lang: {} for lang in LANGUAGES} self.tournaments: dict[str, Tournament] = {} + self.simuls: dict[str, Simul] = {} self.tourney_calendar = None diff --git a/server/routes.py b/server/routes.py index a67e11a99..633eab2c3 100644 --- a/server/routes.py +++ b/server/routes.py @@ -46,6 +46,7 @@ from wsl import lobby_socket_handler from wsr import round_socket_handler from tournament.wst import tournament_socket_handler +from simul.wss import simul_socket_handler from tournament.tournament_calendar import tournament_calendar from twitch import twitch_request_handler from puzzle import puzzle_complete, puzzle_vote @@ -85,6 +86,7 @@ videos, video, winners, + simul as simul_view, ) @@ -132,6 +134,9 @@ (r"/tournament/{tournamentId:\w{8}}", tournament.tournament), (r"/tournament/{tournamentId:\w{8}}/pause", tournament.tournament), (r"/tournament/{tournamentId:\w{8}}/cancel", tournament.tournament), + ("/simuls", simul_view.simuls), + ("/simul/new", simul_view.simul_new), + (r"/simul/{simulId:\w{8}}", simul_view.simul), ("/@/{profileId}", profile.profile), ("/@/{profileId}/tv", tv.tv), ("/@/{profileId}/challenge", lobby.lobby), @@ -156,6 +161,7 @@ ("/wsl", lobby_socket_handler), ("/wsr/{gameId}", round_socket_handler), ("/wst", tournament_socket_handler), + ("/wss", simul_socket_handler), ("/api/account", account), ("/api/account/playing", playing), ("/api/stream/event", event_stream), @@ -212,6 +218,7 @@ ("/translation/select", select_lang), ("/import", import_game), ("/import_bpgn", import_game_bpgn), + ("/simuls/simul", simul_view.simuls), ("/tournaments/new", tournaments.tournaments), (r"/tournaments/{tournamentId:\w{8}}/edit", tournaments.tournaments), ("/twitch", twitch_request_handler), diff --git a/server/server.py b/server/server.py index d41d1be0e..8fca32bd3 100644 --- a/server/server.py +++ b/server/server.py @@ -50,7 +50,8 @@ async def handle_404(request, handler): if ex.status == 404: response = await page404.page404(request) return response - raise + # IMPORTANT: re-raise all other HTTP errors + raise except NotInDbUsers: return web.HTTPFound("/") except asyncio.CancelledError: diff --git a/server/settings.py b/server/settings.py index 1d2eac844..96d5656da 100644 --- a/server/settings.py +++ b/server/settings.py @@ -8,6 +8,8 @@ LOCALHOST = "http://127.0.0.1:8080" URI = os.getenv("URI", LOCALHOST) +SIMULING = URI == LOCALHOST + PROD = os.getenv("PROD") == "true" DEV = not PROD diff --git a/server/simul/__init__.py b/server/simul/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/simul/simul.py b/server/simul/simul.py new file mode 100644 index 000000000..242a92513 --- /dev/null +++ b/server/simul/simul.py @@ -0,0 +1,192 @@ +from __future__ import annotations +import random +import asyncio +from datetime import datetime, timezone +from typing import Set, Dict, TYPE_CHECKING + +from const import T_CREATED, T_STARTED, T_FINISHED, T_ABORTED, RATED, CASUAL +from game import Game +from newid import new_id +from utils import insert_game_to_db +from websocket_utils import ws_send_json + +if TYPE_CHECKING: + from user import User + + +class Simul: + """ + Standalone Simul class + """ + + def __init__( + self, + app_state, + simul_id, + name, + created_by, + variant="chess", + chess960=False, + rated=True, + base=1, + inc=0, + host_color="random", + ): + self.app_state = app_state + self.id = simul_id + self.name = name + self.created_by = created_by + self.variant = variant + self.chess960 = chess960 + self.rated = rated + self.base = base + self.inc = inc + self.host_color = host_color + + self.players: Dict[str, "User"] = {} + self.pending_players: Dict[str, "User"] = {} + self.ongoing_games: Set["Game"] = set() + self.status = T_CREATED + self.created_at = datetime.now(timezone.utc) + self.starts_at = None + self.ends_at = None + self.spectators: Set["User"] = set() + self.tourneychat = [] + + @classmethod + async def create(cls, app_state, simul_id, name, created_by, **kwargs): + simul = cls(app_state, simul_id, name, created_by, **kwargs) + host = await app_state.users.get(created_by) + if host: + simul.players[created_by] = host + return simul + + def join(self, user: "User"): + if ( + user.username != self.created_by + and user.username not in self.players + and user.username not in self.pending_players + ): + self.pending_players[user.username] = user + + def approve(self, username: str): + if username in self.pending_players: + user = self.pending_players[username] + del self.pending_players[username] + self.players[username] = user + + def deny(self, username: str): + if username in self.pending_players: + del self.pending_players[username] + + def leave(self, user: "User"): + if user.username in self.players: + del self.players[user.username] + if user.username in self.pending_players: + del self.pending_players[user.username] + + def add_spectator(self, user: "User"): + self.spectators.add(user) + + def remove_spectator(self, user: "User"): + self.spectators.discard(user) + + async def broadcast(self, response): + for spectator in self.spectators: + if self.id in spectator.simul_sockets: + for ws in spectator.simul_sockets[self.id]: + await ws_send_json(ws, response) + + async def create_games(self): + host = self.players.get(self.created_by) + if host is None: + return + + opponents = [p for p in self.players.values() if p.username != self.created_by] + random.shuffle(opponents) + + game_table = self.app_state.db.game if self.app_state.db else None + + for opponent in opponents: + game_id = await new_id(game_table) + + if self.host_color == "white": + wp, bp = host, opponent + elif self.host_color == "black": + wp, bp = opponent, host + else: # random + if random.choice([True, False]): + wp, bp = host, opponent + else: + wp, bp = opponent, host + + game = Game( + self.app_state, + game_id, + self.variant, + "", # initial_fen + wp, + bp, + base=self.base, + inc=self.inc, + rated=RATED if self.rated else CASUAL, + chess960=self.chess960, + ) + self.ongoing_games.add(game) + self.app_state.games[game_id] = game + await insert_game_to_db(game, self.app_state) + + response = { + "type": "new_game", + "gameId": game.id, + "wplayer": wp.username, + "bplayer": bp.username, + "variant": game.variant, + "fen": game.fen, + "rated": game.rated, + "base": game.base, + "inc": game.inc, + "byo": game.byoyomi_period, + } + await self.broadcast(response) + + async def start(self): + if self.status == T_CREATED: + self.status = T_STARTED + self.starts_at = datetime.now(timezone.utc) + await self.create_games() + self.clock_task = asyncio.create_task(self.clock(), name=f"simul-clock-{self.id}") + + async def finish(self): + if self.status == T_STARTED: + self.status = T_FINISHED + self.ends_at = datetime.now(timezone.utc) + if hasattr(self, "clock_task"): + self.clock_task.cancel() + + async def abort(self): + if self.status == T_CREATED: + self.status = T_ABORTED + self.ends_at = datetime.now(timezone.utc) + + async def game_update(self, game): + response = { + "type": "game_update", + "gameId": game.id, + "fen": game.fen, + "lastMove": game.lastmove, + "status": game.status, + "result": game.result, + } + await self.broadcast(response) + + async def clock(self): + while self.status == T_STARTED: + if len(self.ongoing_games) == 0: + await self.finish() + break + + finished_games = {g for g in self.ongoing_games if g.status > 0} + self.ongoing_games -= finished_games + + await asyncio.sleep(5) diff --git a/server/simul/wss.py b/server/simul/wss.py new file mode 100644 index 000000000..aaa0d76ee --- /dev/null +++ b/server/simul/wss.py @@ -0,0 +1,122 @@ +from __future__ import annotations +import aiohttp_session +from aiohttp import web + +from pychess_global_app_state_utils import get_app_state +from websocket_utils import process_ws, get_user, ws_send_json +from const import TYPE_CHECKING + +if TYPE_CHECKING: + from pychess_global_app_state import PychessGlobalAppState + from user import User + + +async def simul_socket_handler(request): + app_state = get_app_state(request.app) + session = await aiohttp_session.get_session(request) + user = await get_user(session, request) + ws = await process_ws(session, request, user, None, process_message) + if ws is None: + return web.HTTPFound("/") + await finally_logic(app_state, ws, user) + return ws + + +async def finally_logic(app_state: PychessGlobalAppState, ws, user: User): + if user is not None: + for simul_id in list(user.simul_sockets): + if ws in user.simul_sockets[simul_id]: + user.simul_sockets[simul_id].remove(ws) + if len(user.simul_sockets[simul_id]) == 0: + del user.simul_sockets[simul_id] + user.update_online() + simul = app_state.simuls.get(simul_id) + if simul: + simul.remove_spectator(user) + break + + +async def process_message(app_state: PychessGlobalAppState, user: User, ws, data): + if data["type"] == "simul_user_connected": + await handle_simul_user_connected(app_state, ws, user, data) + elif data["type"] == "start_simul": + await handle_start_simul(app_state, user, data) + elif data["type"] == "join": + await handle_join(app_state, user, data) + elif data["type"] == "approve_player": + await handle_approve_player(app_state, user, data) + elif data["type"] == "deny_player": + await handle_deny_player(app_state, user, data) + + +async def handle_simul_user_connected(app_state: PychessGlobalAppState, ws, user: User, data): + simulId = data["simulId"] + simul = app_state.simuls.get(simulId) + if simul is None: + return + + if simulId not in user.simul_sockets: + user.simul_sockets[simulId] = set() + user.simul_sockets[simulId].add(ws) + user.update_online() + + simul.add_spectator(user) + + response = { + "type": "simul_user_connected", + "players": [p.as_json(user.username) for p in simul.players.values()], + "pendingPlayers": [p.as_json(user.username) for p in simul.pending_players.values()], + "createdBy": simul.created_by, + "username": user.username, + } + await ws_send_json(ws, response) + + +async def handle_start_simul(app_state: PychessGlobalAppState, user: User, data): + simulId = data["simulId"] + simul = app_state.simuls.get(simulId) + if simul is None: + return + + if user.username != simul.created_by: + return + + await simul.start() + + +async def handle_join(app_state: PychessGlobalAppState, user: User, data): + simulId = data["simulId"] + simul = app_state.simuls.get(simulId) + if simul is None: + return + + simul.join(user) + await simul.broadcast({"type": "player_joined", "player": user.as_json(user.username)}) + + +async def handle_approve_player(app_state: PychessGlobalAppState, user: User, data): + simulId = data["simulId"] + simul = app_state.simuls.get(simulId) + if simul is None: + return + + if user.username != simul.created_by: + return + + username = data.get("username") + simul.approve(username) + await simul.broadcast({"type": "player_approved", "username": username}) + + +async def handle_deny_player(app_state: PychessGlobalAppState, user: User, data): + simulId = data["simulId"] + simul = app_state.simuls.get(simulId) + if simul is None: + return + + if user.username != simul.created_by: + return + + username = data.get("username") + simul.deny(username) + await simul.broadcast({"type": "player_denied", "username": username}) diff --git a/server/user.py b/server/user.py index 8662b65f9..283ec5fd2 100644 --- a/server/user.py +++ b/server/user.py @@ -78,6 +78,7 @@ def __init__( self.ready_for_auto_pairing = False self.lobby_sockets: Set[WebSocketResponse] = set() self.tournament_sockets: dict[str, WebSocketResponse] = {} # {tournamentId: set()} + self.simul_sockets: dict[str, WebSocketResponse] = {} # {simulId: set()} self.notify_channels: Set[Queue] = set() @@ -173,6 +174,7 @@ def update_online(self): len(self.game_sockets) > 0 or len(self.lobby_sockets) > 0 or len(self.tournament_sockets) > 0 + or len(self.simul_sockets) > 0 ) def get_rating_value(self, variant: str, chess960: bool) -> int: @@ -283,6 +285,7 @@ def as_json(self, requester): "_id": self.username, "title": self.title, "online": True if self.username == requester else self.online, + "simul": len(self.simul_sockets) > 0, } async def clear_seeks(self): diff --git a/server/views/__init__.py b/server/views/__init__.py index 06fbd8cfb..3eb797aa7 100644 --- a/server/views/__init__.py +++ b/server/views/__init__.py @@ -13,7 +13,7 @@ from logger import log from user import User from variants import ALL_VARIANTS - +from settings import SIMULING piece_css_path = Path(Path(__file__).parent.parent.parent, "static/piece-css") piece_sets = [x.name for x in piece_css_path.iterdir() if x.is_dir() and x.name != "mono"] @@ -81,6 +81,7 @@ def variant_display_name(variant): "anon": user.anon, "username": user.username, "piece_sets": piece_sets, + "simuling": SIMULING, } return (user, context) diff --git a/server/views/simul.py b/server/views/simul.py new file mode 100644 index 000000000..92ada9208 --- /dev/null +++ b/server/views/simul.py @@ -0,0 +1,94 @@ +import aiohttp_jinja2 +from aiohttp import web + +from pychess_global_app_state_utils import get_app_state +from misc import time_control_str +from views import get_user_context +from variants import VARIANTS, VARIANT_ICONS +from simul.simul import Simul +from newid import id8 +from const import T_CREATED, T_STARTED, T_FINISHED +from settings import SIMULING + + +@aiohttp_jinja2.template("simuls.html") +async def simuls(request): + if not SIMULING: + raise web.HTTPForbidden() + + user, context = await get_user_context(request) + app_state = get_app_state(request.app) + + if request.path.endswith("/simul"): + data = await request.post() + simul_id = id8() + simul = await Simul.create( + app_state, + simul_id, + name=data["name"], + created_by=user.username, + variant=data["variant"], + base=int(data["base"]), + inc=int(data["inc"]), + host_color=data.get("host_color", "random"), + ) + app_state.simuls[simul_id] = simul + + simuls = list(app_state.simuls.values()) + context["created_simuls"] = [s for s in simuls if s.status == T_CREATED] + context["started_simuls"] = [s for s in simuls if s.status == T_STARTED] + context["finished_simuls"] = [s for s in simuls if s.status == T_FINISHED] + context["icons"] = VARIANT_ICONS + context["time_control_str"] = time_control_str + context["view_css"] = "simul.css" + return context + + +@aiohttp_jinja2.template("simul_new.html") +async def simul_new(request): + if not SIMULING: + raise web.HTTPForbidden() + + user, context = await get_user_context(request) + + context["variants"] = VARIANTS + context["view_css"] = "simul.css" + return context + + +@aiohttp_jinja2.template("index.html") +async def simul(request): + if not SIMULING: + raise web.HTTPForbidden() + + user, context = await get_user_context(request) + app_state = get_app_state(request.app) + + simul_id = request.match_info["simulId"] + simul = app_state.simuls.get(simul_id) + if simul is None: + raise web.HTTPNotFound(text="Simul not found") + + context["simulid"] = simul.id + context["view"] = "simul" + context["status"] = simul.status + context["view_css"] = "simul.css" + return context + + +async def start_simul(request): + if not SIMULING: + raise web.HTTPForbidden() + + user, context = await get_user_context(request) + app_state = get_app_state(request.app) + simulId = request.match_info.get("simulId") + simul = app_state.simuls.get(simulId) + if simul is None: + raise web.HTTPNotFound(text="Simul not found") + + if user.username != simul.created_by: + raise web.HTTPForbidden(text="Only the host can start the simul") + + await simul.start() + return web.Response(text="Simul started") diff --git a/static/simul.css b/static/simul.css new file mode 100644 index 000000000..5ff6e0872 --- /dev/null +++ b/static/simul.css @@ -0,0 +1,560 @@ +*, ::before, ::after { + box-sizing: inherit; + margin: 0; + padding: 0; +} + +.simuls { + grid-area: main; + max-width: 1000px; + margin: auto; + width: 100%; +} + +/* Override main wrap constraint to allow full width for simul pages */ +.simuls #main-wrap, +#pychess-variants[data-view="simul"] #main-wrap { + grid-template-columns: 0 1fr 1fr 1fr 0; + --main-max-width: 100%; +} + +/* For simul pages specifically */ +div#main-wrap[data-view="simul"] { + grid-template-columns: 0 1fr 1fr 1fr 0; + --main-max-width: 100%; +} + +a { + white-space:unset; +} + +.box-pad, .box:not(.box-pad) > h1 { + padding: 5vh var(--box-padding); +} + +.box { + --box-padding: 15px; +} + +@media (min-width: 320px) { + .box { + --box-padding: calc( 15px + 45 * ((100vw - 320px) / 880)); + } +} + +@media (min-width: 1200px) { + .box { + --box-padding: 60px; + } +} + +.box-top { + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: space-between; + margin-bottom: 5vh; +} + +h1 { + font-size: 40px; +} + +.actions { + display: flex; + flex-flow: row wrap; +} + +a.button.icon-plus-square, a.button.calendar { + color: #fff; + background: var(--green-switch); + box-shadow: 0 2px 5px 0 rgba(0,0,0,0.225); + border-radius: 3px; + padding: 0.8em 1em; + border: none; + text-align: center; + white-space: nowrap; + transition: all 150ms; + margin-left: 4px; +} + +.text::before { + margin-right: 0.4em; +} + +th { + font: inherit; + vertical-align: middle; + text-align: inherit; +} + +.slist { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + width: 100%; + border-bottom: 1px solid #d9d9d9; + margin-bottom: 3em; + border-collapse: collapse; +} + +.slist thead { + background: var(--bg-color2); +} + +.slist th:first-child { + padding-left: 1.5rem; +} + +.slist thead th { + border-top: 1px solid #d9d9d9; + border-bottom: 1px solid #d9d9d9; + padding: 0.5rem 0.8rem; +} + +.header a .name, .system, .starts { + display: block; +} + +.header a .name { + font-size: 1.4em; +} + +.by, td .icon { + color: var(--gold); +} + +td .icon { + text-align: center; + font-size: 3.5em; +} + +.slist tbody tr:nth-child(2n) { + background-color: var(--bg-color2) +} + +.slist td { + padding: 1rem; + font: inherit; +} + +.slist td:last-child { + text-align: right; +} + +td a { + display: block; + color: var(--font-color); + padding: 1em; +} + +td.header { + padding: 0; + transition: 150ms all; +} + +td.header:hover { + transform: translateX(3px); +} + +.header .name, .header .system { + letter-spacing: 2px; +} + +.name { + margin-top: 0.5em; +} + +.starts { + opacity: 0.9; + font-size: 90%; +} + +.system { + margin: 0.5em 0; +} + +/* New Simul Form styling */ +.form3 { + margin: 0 auto; + max-width: 500px; + background-color: var(--bg-color); + padding: 2em; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.form3 .form-group { + margin-bottom: 1em; +} + +.form3 .form-label { + display: block; + font-weight: bold; + margin-bottom: 0.5em; +} + +.form3 .form-control { + width: 100%; + padding: 0.5em; + border: 1px solid #ccc; + border-radius: 4px; + background: var(--bg-color2); + color: var(--font-color); +} + +.form3 .form-split { + display: flex; + gap: 1em; +} + +.form3 .form-half { + flex: 1; +} + +.form3 .form-actions { + margin-top: 1.5em; + display: flex; + justify-content: flex-end; + align-items: center; +} + +.form3 .form-actions a { + margin-right: 1em; + color: var(--link-color-blue); + text-decoration: none; +} + +.form3 .form-actions a:hover { + text-decoration: underline; +} + +.form3 .form-actions button { + padding: 0.8em 1.5em; + border: none; + background-color: var(--green-switch); + color: white; + border-radius: 4px; + cursor: pointer; + font-size: 1em; +} + +.form3 .form-actions button:hover { + background-color: var(--green-hover); +} + +/* Simul page styling */ +.simul, .simul-new { + grid-area: main; +} +#simul-view { + padding: 1em; + max-width: 1400px; + margin: 0 auto; +} + +.simul-header { + background: #fff; + border-radius: 4px; + padding: 1.5em; + margin-bottom: 1.5em; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + border-left: 4px solid #75a1a4; +} + +.simul-header h1.simul-title { + font-size: 1.8em; + margin: 0 0 0.5em 0; + display: flex; + align-items: center; + gap: 0.5em; +} + +.simul-status { + font-size: 0.8em; + padding: 0.25em 0.75em; + border-radius: 12px; + font-weight: bold; +} + +.status-waiting { + background-color: #e3f2fd; + color: #1976d2; +} + +.status-started { + background-color: #e8f5e9; + color: #388e3c; +} + +.status-finished { + background-color: #f3e5f5; + color: #7b1fa2; +} + +.simul-info { + display: flex; + gap: 1.5em; + color: #666; + font-size: 0.9em; +} + +.simul-content { + display: grid; + grid-template-columns: minmax(200px, 250px) minmax(60%, 1fr) minmax(200px, 300px); + grid-template-rows: auto auto; + grid-template-areas: + 'side main table' + 'uchat uchat players'; + gap: 15px; + margin-top: 20px; +} + +/* The main area doesn't need a style property since it's the default one */ +.simul-main { + grid-area: main; +} + +.simul-main { + display: flex; + flex-direction: column; + gap: 1em; +} + +.simul-players-section h2 { + margin-top: 0; + border-bottom: 1px solid #eee; + padding-bottom: 0.5em; +} + +.players-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1em; + margin-bottom: 1.5em; +} + +.pending-players, .approved-players { + background: #f9f9f9; + border-radius: 4px; + padding: 1em; + border: 1px solid #e0e0e0; +} + +.pending-players h3, .approved-players h3 { + margin-top: 0; + margin-bottom: 1em; + display: flex; + justify-content: space-between; + align-items: center; + color: #555; +} + +.approved-players h3 { + color: #388e3c; +} + +.pending-players ul, .approved-players ul { + list-style-type: none; + padding: 0; + margin: 0; +} + +.pending-players li, .approved-players li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75em; + border-bottom: 1px solid #eee; + align-items: center; +} + +.pending-players li:last-child, .approved-players li:last-child { + border-bottom: none; +} + +.player-info { + display: flex; + gap: 0.5em; + align-items: center; +} + +.player-info .title { + color: #ff8c00; + font-weight: bold; +} + +.player-info .rating { + color: #777; + font-size: 0.9em; +} + +.player-actions { + display: flex; + gap: 0.5em; +} + +.button { + padding: 0.4em 0.8em; + border: 1px solid #ccc; + border-radius: 4px; + background: #f5f5f5; + cursor: pointer; + font-size: 0.9em; + color: #333; + transition: all 0.2s; +} + +.button:hover { + background: #e0e0e0; +} + +.button.btn-approve { + background: #e8f5e9; + border-color: #388e3c; + color: #388e3c; +} + +.button.btn-approve:hover { + background: #c8e6c9; +} + +.button.btn-deny { + background: #ffebee; + border-color: #d32f2f; + color: #d32f2f; +} + +.button.btn-deny:hover { + background: #ffcdd2; +} + +.empty { + color: #999; + font-style: italic; + padding: 1em; + text-align: center; +} + +/* Board controls */ +.simul-board-controls { + margin-top: 1em; + padding-top: 1em; + border-top: 1px solid #eee; +} + +.simul-board-controls .navigation { + display: flex; + align-items: center; + gap: 0.5em; +} + +.simul-board-controls .nav-btn { + padding: 0.5em; + min-width: 36px; + display: flex; + justify-content: center; + align-items: center; +} + +.simul-board-controls .auto-skip { + margin-left: auto; + background: #fff3cd; + border-color: #ffc107; + color: #856404; +} + +.simul-board-controls .auto-skip:hover { + background: #ffeaa7; +} + +.game-info { + padding: 0 1em; + color: #555; + font-size: 0.9em; +} + +/* Mini boards grid */ +.mini-boards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 1em; + margin: 1em 0; +} + +.mini-board { + border: 1px solid #d1d5db; + border-radius: 4px; + padding: 0.5em; + cursor: pointer; + transition: all 0.2s; + background: #fff; + display: flex; + flex-direction: column; +} + +.mini-board:hover { + border-color: #9ca3af; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.mini-board.active { + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + +.mini-board.finished { + opacity: 0.7; + border-color: #a0a0a0; +} + +.mini-board .cg-wrap { + width: 100%; + aspect-ratio: 1/1; + margin-bottom: 0.5em; +} + +.mini-board .game-info { + font-size: 0.75em; +} + +.mini-board .players { + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 0.25em; +} + +.mini-board .result { + color: #2e7d32; + font-weight: bold; +} + +.mini-board .status { + color: #555; + font-style: italic; +} + +.chat-container { + margin-top: 1.5em; + border-top: 1px solid #eee; + padding-top: 1.5em; +} + +/* Responsive design */ +@media (max-width: 900px) { + .simul-content { + grid-template-columns: 1fr; + grid-template-areas: + 'main' + 'side' + 'table' + 'players' + 'uchat'; + gap: 10px; + } + + .players-grid { + grid-template-columns: 1fr; + } +} diff --git a/templates/base.html b/templates/base.html index f67828350..22ee57dcd 100644 --- a/templates/base.html +++ b/templates/base.html @@ -40,6 +40,7 @@ data-gameid="{{ gameid }}" data-tournamentid="{{ tournamentid }}" data-tournamentname="{{ tournamentname }}" + data-simulid="{{ simulid }}" data-inviter="{{ inviter }}" data-ply="{{ ply }}" data-initialfen="{{ initialFen}}" diff --git a/templates/simul_new.html b/templates/simul_new.html new file mode 100644 index 000000000..eec28947f --- /dev/null +++ b/templates/simul_new.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

Host a new simul

+
+
+ +
+ +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ Cancel + +
+
+
+
+
+{% endblock %} diff --git a/templates/simuls.html b/templates/simuls.html new file mode 100644 index 000000000..22c1a8382 --- /dev/null +++ b/templates/simuls.html @@ -0,0 +1,121 @@ +{% extends "template.html" %} +{% block content %} +
+
+
+

{% trans %}Simultaneous Exhibitions{% endtrans %}

+ +
+ + + + + + + + + {% for simul in created_simuls %} + + + + + + + {% endfor %} + +
{% trans %}Newly created simuls{% endtrans %}
+
+
+ + {{ simul.name }}by + {{ simul.created_by }} + + {{ time_control_str(simul.base, simul.inc, 0) }} • + {{ variant_display_name(simul.variant + ('960' if simul.chess960 else '')) }} + + + + + + + + {% trans count=simul.players|length %}{{ count }} player{% pluralize %}{{ count }} players{% endtrans %} +
+ + + + + + + + + {% for simul in started_simuls %} + + + + + + + {% endfor %} + +
{% trans %}Playing now{% endtrans %}
+
+
+ + {{ simul.name }}by + {{ simul.created_by }} + + {{ time_control_str(simul.base, simul.inc, 0) }} • + {{ variant_display_name(simul.variant + ('960' if simul.chess960 else '')) }} + + + + + + + + {% trans count=simul.players|length %}{{ count }} player{% pluralize %}{{ count }} players{% endtrans %} +
+ + + + + + + + + {% for simul in finished_simuls %} + + + + + + + {% endfor %} + +
{% trans %}Finished{% endtrans %}
+
+
+ + {{ simul.name }}by + {{ simul.created_by }} + + {{ time_control_str(simul.base, simul.inc, 0) }} • + {{ variant_display_name(simul.variant + ('960' if simul.chess960 else '')) }} + + + + + + + + {% trans count=simul.players|length %}{{ count }} player{% pluralize %}{{ count }} players{% endtrans %} +
+
+
+{% endblock %} +{% block js %} + +{% endblock %} diff --git a/templates/template.html b/templates/template.html index 07311e4c9..bcf9eb9da 100644 --- a/templates/template.html +++ b/templates/template.html @@ -15,6 +15,9 @@
{% trans %}Create a game{% endtrans %} {% trans %}Tournaments{% endtrans %} + {% if simuling %} + {% trans %}Simultaneous exhibitions{% endtrans %} + {% endif %}
diff --git a/tests/test_simul.py b/tests/test_simul.py new file mode 100644 index 000000000..544e54a5b --- /dev/null +++ b/tests/test_simul.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +import json +import logging +import time +import aiohttp +import pytest + +from mongomock_motor import AsyncMongoMockClient + +from const import T_STARTED +from newid import id8 +from pychess_global_app_state_utils import get_app_state +from server import make_app +from simul.simul import Simul +from user import User +from logger import log + + +log.setLevel(level=logging.DEBUG) + + +@pytest.mark.asyncio +class TestGUI: + async def test_simul_creation_and_pairing(self, aiohttp_server): + app = make_app(db_client=AsyncMongoMockClient()) + await aiohttp_server(app, host="127.0.0.1", port=8080) + app_state = get_app_state(app) + NB_PLAYERS = 5 + host_username = "TestUser_1" + sid = id8() + + host = User(app_state, username=host_username) + app_state.users[host.username] = host + + simul = await Simul.create(app_state, sid, name="Test Simul", created_by=host_username) + app_state.simuls[sid] = simul + + assert len(simul.players) == 1 # Host is automatically a player + + for i in range(2, NB_PLAYERS + 1): + player = User(app_state, username=f"TestUser_{i}") + app_state.users[player.username] = player + simul.join(player) + simul.approve(player.username) + + assert len(simul.players) == NB_PLAYERS + + await simul.start() + + assert simul.status == T_STARTED + assert len(simul.ongoing_games) == NB_PLAYERS - 1 + + for game in simul.ongoing_games: + assert game.wplayer.username == host_username or game.bplayer.username == host_username + + async def test_simul_join_approve_and_deny(self, aiohttp_server): + app = make_app(db_client=AsyncMongoMockClient()) + await aiohttp_server(app, host="127.0.0.1", port=8080) + app_state = get_app_state(app) + host_username = "TestUser_1" + sid = id8() + + host = User(app_state, username=host_username) + app_state.users[host.username] = host + + simul = await Simul.create(app_state, sid, name="Test Simul", created_by=host_username) + app_state.simuls[sid] = simul + + player2 = User(app_state, username="TestUser_2") + app_state.users[player2.username] = player2 + simul.join(player2) + + player3 = User(app_state, username="TestUser_3") + app_state.users[player3.username] = player3 + simul.join(player3) + + assert len(simul.pending_players) == 2 + + simul.approve(player2.username) + assert len(simul.pending_players) == 1 + assert len(simul.players) == 2 # Host + player2 + assert player2.username in simul.players + + simul.deny(player3.username) + assert len(simul.pending_players) == 0 + assert player3.username not in simul.players + + async def test_simul_websocket(self, aiohttp_server): + app = make_app( + db_client=AsyncMongoMockClient(), simple_cookie_storage=True, anon_as_test_users=True + ) + await aiohttp_server(app, host="127.0.0.1", port=8080) + app_state = get_app_state(app) + host_username = "TestUser_1" + sid = id8() + + host = User(app_state, username=host_username) + app_state.users[host.username] = host + + simul = await Simul.create(app_state, sid, name="Test Simul", created_by=host_username) + app_state.simuls[sid] = simul + + async with aiohttp.ClientSession() as session: + session_data = {"session": {"user_name": host_username}, "created": int(time.time())} + value = json.dumps(session_data) + session.cookie_jar.update_cookies({"AIOHTTP_SESSION": value}) + + client = await session.ws_connect("ws://127.0.0.1:8080/wss") + + await client.send_json( + {"type": "simul_user_connected", "username": host_username, "simulId": sid} + ) + msg = await client.receive_json() + assert msg["type"] == "simul_user_connected" + assert msg["username"] == host_username + + player2 = User(app_state, username="TestUser_2") + app_state.users[player2.username] = player2 + + await client.send_json({"type": "join", "simulId": sid}) + msg = await client.receive_json() + assert msg["type"] == "player_joined" + + await client.send_json( + {"type": "approve_player", "simulId": sid, "username": "TestUser_2"} + ) + msg = await client.receive_json() + assert msg["type"] == "player_approved" + assert msg["username"] == "TestUser_2"