feat(games): add solo mode to room creation and AU currency betting
Some checks failed
Deploy to Production / test (push) Failing after 31s

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) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-05 18:09:03 +02:00
parent 4f89ed3082
commit f368da9e73
8 changed files with 283 additions and 24 deletions

View File

@@ -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<WsConnectionData>,
): Promise<void> {
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<void> {
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));
}

View File

@@ -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<string, unknown>();
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;

View File

@@ -10,6 +10,9 @@ export interface Room {
status: "waiting" | "playing" | "finished";
createdAt: number;
options?: Record<string, unknown>;
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 };