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 %}
+
+{% 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 %}
+
+
+
+
+
+
+ | {% trans %}Newly created simuls{% endtrans %} |
+
+
+
+ {% for simul in created_simuls %}
+
+ |
+
+ |
+
+
+
+
+
+ |
+
+ {% trans count=simul.players|length %}{{ count }} player{% pluralize %}{{ count }} players{% endtrans %}
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+ | {% trans %}Playing now{% endtrans %} |
+
+
+
+ {% for simul in started_simuls %}
+
+ |
+
+ |
+
+
+
+
+
+ |
+
+ {% trans count=simul.players|length %}{{ count }} player{% pluralize %}{{ count }} players{% endtrans %}
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+ | {% trans %}Finished{% endtrans %} |
+
+
+
+ {% for simul in finished_simuls %}
+
+ |
+
+ |
+
+
+
+
+
+ |
+
+ {% trans count=simul.players|length %}{{ count }} player{% pluralize %}{{ count }} players{% endtrans %}
+ |
+
+ {% endfor %}
+
+
+
+
+{% 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 @@
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"