From f368da9e73ee1fdaebb059e143e89ba0ac128445 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sun, 5 Apr 2026 18:09:03 +0200 Subject: [PATCH] feat(games): add solo mode to room creation and AU currency betting Solo mode is now a toggle in the chess room creation modal, available to all users instead of admin-only. Betting lets players wager AU on games with preset amounts, async deduction on game start, and automatic payout/refund on game end. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/games/GameServer.ts | 160 +++++++++++++++++++++++++++- api/src/games/RoomManager.ts | 49 ++++++--- api/src/games/types.ts | 8 +- panel/src/games/GameLobby.tsx | 62 ++++++++++- panel/src/games/GameRoom.tsx | 18 +++- panel/src/games/chess/ChessGame.tsx | 1 + panel/src/lib/useGameRoom.ts | 7 +- shared/lib/constants.ts | 2 + 8 files changed, 283 insertions(+), 24 deletions(-) diff --git a/api/src/games/GameServer.ts b/api/src/games/GameServer.ts index b795eaf..023d502 100644 --- a/api/src/games/GameServer.ts +++ b/api/src/games/GameServer.ts @@ -2,6 +2,8 @@ import { RoomManager } from "./RoomManager"; import { GameWsClientSchema } from "./types"; import type { PlayerInfo } from "./types"; import { logger } from "@shared/lib/logger"; +import { economyService } from "@shared/modules/economy/economy.service"; +import { TransactionType } from "@shared/lib/constants"; import type { Server, ServerWebSocket } from "bun"; export interface WsConnectionData { @@ -58,6 +60,24 @@ export class GameServer { }); this.roomManager.emitter.on("game:ended", ({ roomId, winner, reason }) => { + const room = this.roomManager.getRoom(roomId); + const betAmount = room?.betAmount ?? 0; + + // Handle bet payouts asynchronously — broadcast happens after settlement + if (betAmount > 0) { + this.settleBets(roomId, winner, betAmount).then((payout) => { + this.publish(`room:${roomId}`, { + type: "GAME_ENDED", + roomId, + winner, + reason, + payout, + }); + this.publishRoomListUpdate(); + }); + return; + } + this.publish(`room:${roomId}`, { type: "GAME_ENDED", roomId, @@ -117,7 +137,11 @@ export class GameServer { switch (msg.type) { case "CREATE_ROOM": { - const result = this.roomManager.createRoom(msg.gameType, discordId, msg.options); + // Solo mode forces betAmount to 0 + const options = msg.options ? { ...msg.options } : {}; + if (options.soloMode) options.betAmount = 0; + + const result = this.roomManager.createRoom(msg.gameType, discordId, options); if (!result.ok) { ws.send(JSON.stringify({ type: "ERROR", message: result.error })); return; @@ -126,6 +150,15 @@ export class GameServer { ws.data.rooms.add(result.roomId); ws.send(JSON.stringify({ type: "ROOM_CREATED", roomId: result.roomId, gameSlug: msg.gameType })); logger.info("web", `Room created: ${result.roomId} (${msg.gameType}) by ${discordId}`); + + // Solo mode: auto-fill and start immediately + if (options.soloMode) { + const fillResult = this.roomManager.fillRoom(result.roomId, discordId); + if (!fillResult.ok) { + ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error })); + } + // fillRoom with betAmount=0 calls startGame internally + } break; } @@ -169,6 +202,9 @@ export class GameServer { } this.replacedConnections.delete(discordId); + // Build room options for the client + const roomOptions = room?.betAmount ? { betAmount: room.betAmount } : undefined; + // Respond with JOIN_RESULT ws.send(JSON.stringify({ type: "JOIN_RESULT", @@ -178,6 +214,7 @@ export class GameServer { players, spectators, state, + roomOptions, })); // Notify other room members @@ -190,6 +227,11 @@ export class GameServer { }); logger.info("web", `${discordId} joined room ${msg.roomId} as ${result.joinedAs}`); + + // Handle async bet deduction when room is ready to start + if (result.readyToStart && room) { + this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws); + } break; } @@ -215,11 +257,15 @@ export class GameServer { ws.send(JSON.stringify({ type: "ERROR", message: "Only admins can fill a room for solo testing" })); return; } - const result = this.roomManager.fillRoom(msg.roomId, discordId); - if (!result.ok) { - ws.send(JSON.stringify({ type: "ERROR", message: result.error })); + const fillResult = this.roomManager.fillRoom(msg.roomId, discordId); + if (!fillResult.ok) { + ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error })); return; } + if (fillResult.readyToStart) { + const room = this.roomManager.getRoom(msg.roomId); + if (room) this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws); + } logger.info("web", `Admin ${discordId} filled room ${msg.roomId} for solo testing`); break; } @@ -241,6 +287,112 @@ export class GameServer { this.connections.delete(ws.data.session.discordId); } + /** + * Deduct bet amounts from all players, then start the game. + * If any player can't afford the bet, refund already-deducted players + * and remove the failing player from the room. + */ + private async deductBetsAndStart( + roomId: string, + betAmount: number, + playerIds: string[], + triggeringWs: ServerWebSocket, + ): Promise { + const room = this.roomManager.getRoom(roomId); + if (!room || room.betsPending) return; + room.betsPending = true; + + const uniquePlayers = [...new Set(playerIds)]; + const deducted: string[] = []; + + try { + for (const pid of uniquePlayers) { + await economyService.modifyUserBalance( + pid, + -BigInt(betAmount), + TransactionType.GAME_BET, + `Chess wager (room ${roomId.slice(0, 8)})`, + ); + deducted.push(pid); + } + + // All deductions succeeded — start the game + const startResult = this.roomManager.startGame(roomId); + if (!startResult.ok) { + // Shouldn't happen, but refund if it does + await this.refundPlayers(deducted, betAmount, roomId); + } + } catch (err) { + // Refund anyone already deducted + await this.refundPlayers(deducted, betAmount, roomId); + + // Find the player who couldn't afford the bet + const failedPlayer = uniquePlayers.find(p => !deducted.includes(p)); + if (failedPlayer) { + this.roomManager.removePlayer(roomId, failedPlayer); + this.sendToPlayer(failedPlayer, { + type: "ERROR", + message: "Insufficient funds for the bet. You have been removed from the room.", + }); + this.publish(`room:${roomId}`, { + type: "PLAYER_LEFT", + roomId, + playerId: failedPlayer, + }); + } + + logger.warn("web", `Bet deduction failed for room ${roomId}: ${err}`); + } finally { + if (room) room.betsPending = false; + } + } + + /** Pay out winnings or refund bets on game end. */ + private async settleBets( + roomId: string, + winner: string | null, + betAmount: number, + ): Promise<{ amount: number; refunded?: boolean }> { + const room = this.roomManager.getRoom(roomId); + const uniquePlayers = [...new Set(room?.players ?? [])]; + const pot = betAmount * uniquePlayers.length; + + try { + if (winner) { + // Winner takes the pot + await economyService.modifyUserBalance( + winner, + BigInt(pot), + TransactionType.GAME_WIN, + `Chess wager won (room ${roomId.slice(0, 8)})`, + ); + return { amount: pot }; + } else { + // Draw — refund all players + await this.refundPlayers(uniquePlayers, betAmount, roomId); + return { amount: betAmount, refunded: true }; + } + } catch (err) { + logger.error("web", `Bet settlement failed for room ${roomId}: ${err}`); + return { amount: 0 }; + } + } + + private async refundPlayers(playerIds: string[], betAmount: number, roomId: string): Promise { + for (const pid of playerIds) { + try { + await economyService.modifyUserBalance( + pid, + BigInt(betAmount), + TransactionType.GAME_WIN, + `Chess wager refund (room ${roomId.slice(0, 8)})`, + ); + } catch (err) { + logger.error("web", `Failed to refund ${pid} for room ${roomId}: ${err}`); + } + } + } + private publish(channel: string, message: unknown): void { this.bunServer?.publish(channel, JSON.stringify(message)); } diff --git a/api/src/games/RoomManager.ts b/api/src/games/RoomManager.ts index f9e5591..7b516eb 100644 --- a/api/src/games/RoomManager.ts +++ b/api/src/games/RoomManager.ts @@ -14,8 +14,9 @@ type ActionResult = type CreateResult = { ok: true; roomId: string } | { ok: false; error: string }; type JoinResult = - | { ok: true; joinedAs: "player" | "spectator"; started: boolean } + | { ok: true; joinedAs: "player" | "spectator"; started: boolean; readyToStart?: boolean } | { ok: false; error: string }; +type FillResult = { ok: true; readyToStart?: boolean } | { ok: false; error: string }; type RoomEvents = { "room:created": { roomId: string; gameSlug: string; hostId: string }; @@ -38,6 +39,7 @@ export class RoomManager { if (!plugin) return { ok: false, error: `Unknown game type: ${gameSlug}` }; const id = crypto.randomUUID(); + const betAmount = typeof options?.betAmount === "number" && options.betAmount > 0 ? options.betAmount : 0; const room: Room = { id, gameSlug, @@ -48,6 +50,7 @@ export class RoomManager { status: "waiting", createdAt: Date.now(), options, + betAmount, }; this.rooms.set(id, room); @@ -86,17 +89,12 @@ export class RoomManager { room.players.push(playerId); if (room.players.length >= plugin.maxPlayers) { - room.state = plugin.createInitialState(room.players, room.options); - room.status = "playing"; - this.scheduleCleanup(roomId, ROOM_CONFIG.PLAYING_MAX_MS); - - const spectatorView = plugin.getSpectatorView(room.state); - const playerViews = new Map(); - for (const pid of room.players) { - playerViews.set(pid, plugin.getPlayerView(room.state, pid)); + // Defer start when bets are involved — GameServer handles async deduction first + if (room.betAmount > 0) { + this.emitter.emit("room:list:changed"); + return { ok: true, joinedAs: "player", started: false, readyToStart: true }; } - this.emitter.emit("game:started", { roomId, spectatorView, playerViews }); - this.emitter.emit("room:list:changed"); + this.startGame(roomId); return { ok: true, joinedAs: "player", started: true }; } @@ -175,7 +173,7 @@ export class RoomManager { * (e.g. ["alice", "alice"]). Plugin authors should be aware that * solo-test mode produces non-unique player arrays. */ - fillRoom(roomId: string, adminId: string): { ok: true } | { ok: false; error: string } { + fillRoom(roomId: string, adminId: string): FillResult { const room = this.rooms.get(roomId); if (!room) return { ok: false, error: "Room not found" }; if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" }; @@ -186,6 +184,23 @@ export class RoomManager { room.players.push(adminId); } + // Defer start when bets are involved + if (room.betAmount > 0) { + this.emitter.emit("room:list:changed"); + return { ok: true, readyToStart: true }; + } + + this.startGame(roomId); + return { ok: true }; + } + + /** Initialize game state and transition room to playing. */ + startGame(roomId: string): { ok: true } | { ok: false; error: string } { + const room = this.rooms.get(roomId); + if (!room) return { ok: false, error: "Room not found" }; + if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" }; + + const plugin = gameRegistry.get(room.gameSlug)!; room.state = plugin.createInitialState(room.players, room.options); room.status = "playing"; this.scheduleCleanup(roomId, ROOM_CONFIG.PLAYING_MAX_MS); @@ -200,6 +215,15 @@ export class RoomManager { return { ok: true }; } + /** Remove a player from a waiting room (used when bet deduction fails). */ + removePlayer(roomId: string, playerId: string): void { + const room = this.rooms.get(roomId); + if (!room || room.status !== "waiting") return; + const idx = room.players.indexOf(playerId); + if (idx !== -1) room.players.splice(idx, 1); + this.emitter.emit("room:list:changed"); + } + getRoom(roomId: string): Room | undefined { return this.rooms.get(roomId); } @@ -218,6 +242,7 @@ export class RoomManager { maxPlayers: plugin?.maxPlayers ?? 0, spectatorCount: room.spectators.size, status: room.status, + betAmount: room.betAmount, }); } return summaries; diff --git a/api/src/games/types.ts b/api/src/games/types.ts index 1510365..67449dc 100644 --- a/api/src/games/types.ts +++ b/api/src/games/types.ts @@ -10,6 +10,9 @@ export interface Room { status: "waiting" | "playing" | "finished"; createdAt: number; options?: Record; + betAmount: number; + /** Guard against double bet-deduction when two joins race */ + betsPending?: boolean; } export interface RoomSummary { @@ -21,6 +24,7 @@ export interface RoomSummary { maxPlayers: number; spectatorCount: number; status: "waiting" | "playing" | "finished"; + betAmount: number; } export interface PlayerInfo { @@ -51,8 +55,8 @@ export type GameWsServerMessage = | { type: "PLAYER_JOINED"; roomId: string; player: PlayerInfo; joinedAs: "player" | "spectator" } | { type: "PLAYER_LEFT"; roomId: string; playerId: string } | { type: "GAME_STARTED"; roomId: string; state: unknown } - | { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string } + | { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } } | { type: "ROOM_CREATED"; roomId: string; gameSlug: string } - | { type: "JOIN_RESULT"; roomId: string; joinedAs: "player" | "spectator"; roomStatus: "waiting" | "playing" | "finished"; players: PlayerInfo[]; spectators: PlayerInfo[]; state?: unknown } + | { type: "JOIN_RESULT"; roomId: string; joinedAs: "player" | "spectator"; roomStatus: "waiting" | "playing" | "finished"; players: PlayerInfo[]; spectators: PlayerInfo[]; state?: unknown; roomOptions?: { betAmount?: number } } | { type: "SESSION_REPLACED"; roomId: string } | { type: "ERROR"; message: string }; diff --git a/panel/src/games/GameLobby.tsx b/panel/src/games/GameLobby.tsx index 050bde7..28927bb 100644 --- a/panel/src/games/GameLobby.tsx +++ b/panel/src/games/GameLobby.tsx @@ -14,6 +14,7 @@ interface RoomSummary { maxPlayers: number; spectatorCount: number; status: "waiting" | "playing" | "finished"; + betAmount: number; } const CHESS_TIME_CONTROLS = [ @@ -37,6 +38,8 @@ export function GameLobby() { const [showCreate, setShowCreate] = useState(false); const [configGame, setConfigGame] = useState(null); const [chessTimeControl, setChessTimeControl] = useState("blitz_5_3"); + const [soloMode, setSoloMode] = useState(false); + const [betAmount, setBetAmount] = useState(0); const gameTypes = gameUIRegistry.list(); @@ -61,6 +64,8 @@ export function GameLobby() { function handleGameSelect(gameSlug: string) { if (gameSlug === "chess") { setConfigGame("chess"); + setSoloMode(false); + setBetAmount(0); return; } send({ type: "CREATE_ROOM", gameType: gameSlug }); @@ -68,9 +73,19 @@ export function GameLobby() { } function createChessRoom() { - send({ type: "CREATE_ROOM", gameType: "chess", options: { timeControl: chessTimeControl } }); + send({ + type: "CREATE_ROOM", + gameType: "chess", + options: { + timeControl: chessTimeControl, + ...(soloMode && { soloMode: true }), + ...(betAmount > 0 && !soloMode && { betAmount }), + }, + }); setShowCreate(false); setConfigGame(null); + setSoloMode(false); + setBetAmount(0); } return ( @@ -138,6 +153,11 @@ export function GameLobby() { {room.status === "waiting" ? "Waiting" : "Playing"} {room.playerCount}/{room.maxPlayers} players + {room.betAmount > 0 && ( + + {room.betAmount} AU + + )} {room.spectatorCount > 0 && · 👁 {room.spectatorCount}} @@ -203,11 +223,49 @@ export function GameLobby() { })} + {/* Solo Mode Toggle */} +
+
+
Solo Play
+
Play both sides yourself
+
+ +
+ + {/* Bet Amount */} +
+
+ Wager (AU) +
+
+ {[0, 10, 25, 50, 100, 250, 500].map(amt => ( + + ))} +
+
+ ) : ( diff --git a/panel/src/games/GameRoom.tsx b/panel/src/games/GameRoom.tsx index a6565a3..dec9a4b 100644 --- a/panel/src/games/GameRoom.tsx +++ b/panel/src/games/GameRoom.tsx @@ -42,9 +42,11 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) { const { gameState, players, spectators, roomStatus, - isSpectator, gameOver, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom, + isSpectator, gameOver, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom, roomOptions, } = useGameRoom(roomId!, userId, role, preferAs); + const betAmount = roomOptions.betAmount ?? 0; + const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined; if (!plugin) { @@ -100,6 +102,11 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) { {roomStatus === "waiting" ? "Waiting" : roomStatus === "playing" ? "Playing" : "Finished"} {isSpectator && Spectating} + {betAmount > 0 && ( + + {betAmount * 2} AU pot + + )} 👁 {spectators.length} @@ -139,7 +146,14 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) { ? `Winner: ${players.find(p => p.discordId === gameOver.winner)?.username ?? gameOver.winner}` : "Draw!"} -
Reason: {gameOver.reason}
+
{gameOver.reason}
+ {gameOver.payout && ( +
+ {gameOver.payout.refunded + ? `Wager refunded: ${gameOver.payout.amount} AU` + : `Payout: ${gameOver.payout.amount} AU`} +
+ )} )} diff --git a/panel/src/games/chess/ChessGame.tsx b/panel/src/games/chess/ChessGame.tsx index 9f5eca6..99243a5 100644 --- a/panel/src/games/chess/ChessGame.tsx +++ b/panel/src/games/chess/ChessGame.tsx @@ -315,6 +315,7 @@ export function ChessGame({ state, myPlayerId, isSpectator, onAction, players }: // Resolve player names const getPlayerName = (color: "white" | "black") => { if (isPlayerView(state)) { + if (isSoloMode) return players[0]?.username ?? color; const id = color === (state as PlayerView).myColor ? myPlayerId : players.find(p => p.discordId !== myPlayerId)?.discordId; return players.find(p => p.discordId === id)?.username ?? (color === myColor ? "You" : "Opponent"); } diff --git a/panel/src/lib/useGameRoom.ts b/panel/src/lib/useGameRoom.ts index b9aef8e..2997a27 100644 --- a/panel/src/lib/useGameRoom.ts +++ b/panel/src/lib/useGameRoom.ts @@ -13,9 +13,10 @@ interface GameRoomState { spectators: PlayerInfo[]; roomStatus: "connecting" | "waiting" | "playing" | "finished" | "not_found"; isSpectator: boolean; - gameOver: { winner: string | null; reason: string } | null; + gameOver: { winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } } | null; error: string | null; sessionReplaced: boolean; + roomOptions: { betAmount?: number }; } export function useGameRoom(roomId: string, userId: string, role?: string, preferAs: "player" | "spectator" = "player") { @@ -37,6 +38,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe gameOver: null, error: null, sessionReplaced: false, + roomOptions: {}, }); useEffect(() => { @@ -56,6 +58,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe players: msg.players ?? prev.players, spectators: msg.spectators ?? prev.spectators, gameState: msg.state !== undefined ? msg.state : prev.gameState, + roomOptions: msg.roomOptions ?? prev.roomOptions, })); break; @@ -117,7 +120,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe setState(prev => ({ ...prev, roomStatus: "finished", - gameOver: { winner: msg.winner, reason: msg.reason }, + gameOver: { winner: msg.winner, reason: msg.reason, payout: msg.payout }, })); break; diff --git a/shared/lib/constants.ts b/shared/lib/constants.ts index 0962f51..5303a04 100644 --- a/shared/lib/constants.ts +++ b/shared/lib/constants.ts @@ -39,6 +39,8 @@ export enum TransactionType { QUEST_REWARD = 'QUEST_REWARD', TRIVIA_ENTRY = 'TRIVIA_ENTRY', TRIVIA_WIN = 'TRIVIA_WIN', + GAME_BET = 'GAME_BET', + GAME_WIN = 'GAME_WIN', } export enum ItemTransactionType {