feat(games): add solo mode to room creation and AU currency betting
Some checks failed
Deploy to Production / test (push) Failing after 31s
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:
@@ -2,6 +2,8 @@ import { RoomManager } from "./RoomManager";
|
|||||||
import { GameWsClientSchema } from "./types";
|
import { GameWsClientSchema } from "./types";
|
||||||
import type { PlayerInfo } from "./types";
|
import type { PlayerInfo } from "./types";
|
||||||
import { logger } from "@shared/lib/logger";
|
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";
|
import type { Server, ServerWebSocket } from "bun";
|
||||||
|
|
||||||
export interface WsConnectionData {
|
export interface WsConnectionData {
|
||||||
@@ -58,6 +60,24 @@ export class GameServer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.roomManager.emitter.on("game:ended", ({ roomId, winner, reason }) => {
|
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}`, {
|
this.publish(`room:${roomId}`, {
|
||||||
type: "GAME_ENDED",
|
type: "GAME_ENDED",
|
||||||
roomId,
|
roomId,
|
||||||
@@ -117,7 +137,11 @@ export class GameServer {
|
|||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "CREATE_ROOM": {
|
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) {
|
if (!result.ok) {
|
||||||
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||||
return;
|
return;
|
||||||
@@ -126,6 +150,15 @@ export class GameServer {
|
|||||||
ws.data.rooms.add(result.roomId);
|
ws.data.rooms.add(result.roomId);
|
||||||
ws.send(JSON.stringify({ type: "ROOM_CREATED", roomId: result.roomId, gameSlug: msg.gameType }));
|
ws.send(JSON.stringify({ type: "ROOM_CREATED", roomId: result.roomId, gameSlug: msg.gameType }));
|
||||||
logger.info("web", `Room created: ${result.roomId} (${msg.gameType}) by ${discordId}`);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +202,9 @@ export class GameServer {
|
|||||||
}
|
}
|
||||||
this.replacedConnections.delete(discordId);
|
this.replacedConnections.delete(discordId);
|
||||||
|
|
||||||
|
// Build room options for the client
|
||||||
|
const roomOptions = room?.betAmount ? { betAmount: room.betAmount } : undefined;
|
||||||
|
|
||||||
// Respond with JOIN_RESULT
|
// Respond with JOIN_RESULT
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: "JOIN_RESULT",
|
type: "JOIN_RESULT",
|
||||||
@@ -178,6 +214,7 @@ export class GameServer {
|
|||||||
players,
|
players,
|
||||||
spectators,
|
spectators,
|
||||||
state,
|
state,
|
||||||
|
roomOptions,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Notify other room members
|
// Notify other room members
|
||||||
@@ -190,6 +227,11 @@ export class GameServer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.info("web", `${discordId} joined room ${msg.roomId} as ${result.joinedAs}`);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,11 +257,15 @@ export class GameServer {
|
|||||||
ws.send(JSON.stringify({ type: "ERROR", message: "Only admins can fill a room for solo testing" }));
|
ws.send(JSON.stringify({ type: "ERROR", message: "Only admins can fill a room for solo testing" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = this.roomManager.fillRoom(msg.roomId, discordId);
|
const fillResult = this.roomManager.fillRoom(msg.roomId, discordId);
|
||||||
if (!result.ok) {
|
if (!fillResult.ok) {
|
||||||
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error }));
|
||||||
return;
|
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`);
|
logger.info("web", `Admin ${discordId} filled room ${msg.roomId} for solo testing`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -241,6 +287,112 @@ export class GameServer {
|
|||||||
this.connections.delete(ws.data.session.discordId);
|
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 {
|
private publish(channel: string, message: unknown): void {
|
||||||
this.bunServer?.publish(channel, JSON.stringify(message));
|
this.bunServer?.publish(channel, JSON.stringify(message));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ type ActionResult =
|
|||||||
|
|
||||||
type CreateResult = { ok: true; roomId: string } | { ok: false; error: string };
|
type CreateResult = { ok: true; roomId: string } | { ok: false; error: string };
|
||||||
type JoinResult =
|
type JoinResult =
|
||||||
| { ok: true; joinedAs: "player" | "spectator"; started: boolean }
|
| { ok: true; joinedAs: "player" | "spectator"; started: boolean; readyToStart?: boolean }
|
||||||
| { ok: false; error: string };
|
| { ok: false; error: string };
|
||||||
|
type FillResult = { ok: true; readyToStart?: boolean } | { ok: false; error: string };
|
||||||
|
|
||||||
type RoomEvents = {
|
type RoomEvents = {
|
||||||
"room:created": { roomId: string; gameSlug: string; hostId: string };
|
"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}` };
|
if (!plugin) return { ok: false, error: `Unknown game type: ${gameSlug}` };
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
const betAmount = typeof options?.betAmount === "number" && options.betAmount > 0 ? options.betAmount : 0;
|
||||||
const room: Room = {
|
const room: Room = {
|
||||||
id,
|
id,
|
||||||
gameSlug,
|
gameSlug,
|
||||||
@@ -48,6 +50,7 @@ export class RoomManager {
|
|||||||
status: "waiting",
|
status: "waiting",
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
options,
|
options,
|
||||||
|
betAmount,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.rooms.set(id, room);
|
this.rooms.set(id, room);
|
||||||
@@ -86,17 +89,12 @@ export class RoomManager {
|
|||||||
room.players.push(playerId);
|
room.players.push(playerId);
|
||||||
|
|
||||||
if (room.players.length >= plugin.maxPlayers) {
|
if (room.players.length >= plugin.maxPlayers) {
|
||||||
room.state = plugin.createInitialState(room.players, room.options);
|
// Defer start when bets are involved — GameServer handles async deduction first
|
||||||
room.status = "playing";
|
if (room.betAmount > 0) {
|
||||||
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));
|
|
||||||
}
|
|
||||||
this.emitter.emit("game:started", { roomId, spectatorView, playerViews });
|
|
||||||
this.emitter.emit("room:list:changed");
|
this.emitter.emit("room:list:changed");
|
||||||
|
return { ok: true, joinedAs: "player", started: false, readyToStart: true };
|
||||||
|
}
|
||||||
|
this.startGame(roomId);
|
||||||
return { ok: true, joinedAs: "player", started: true };
|
return { ok: true, joinedAs: "player", started: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +173,7 @@ export class RoomManager {
|
|||||||
* (e.g. ["alice", "alice"]). Plugin authors should be aware that
|
* (e.g. ["alice", "alice"]). Plugin authors should be aware that
|
||||||
* solo-test mode produces non-unique player arrays.
|
* 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);
|
const room = this.rooms.get(roomId);
|
||||||
if (!room) return { ok: false, error: "Room not found" };
|
if (!room) return { ok: false, error: "Room not found" };
|
||||||
if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" };
|
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);
|
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.state = plugin.createInitialState(room.players, room.options);
|
||||||
room.status = "playing";
|
room.status = "playing";
|
||||||
this.scheduleCleanup(roomId, ROOM_CONFIG.PLAYING_MAX_MS);
|
this.scheduleCleanup(roomId, ROOM_CONFIG.PLAYING_MAX_MS);
|
||||||
@@ -200,6 +215,15 @@ export class RoomManager {
|
|||||||
return { ok: true };
|
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 {
|
getRoom(roomId: string): Room | undefined {
|
||||||
return this.rooms.get(roomId);
|
return this.rooms.get(roomId);
|
||||||
}
|
}
|
||||||
@@ -218,6 +242,7 @@ export class RoomManager {
|
|||||||
maxPlayers: plugin?.maxPlayers ?? 0,
|
maxPlayers: plugin?.maxPlayers ?? 0,
|
||||||
spectatorCount: room.spectators.size,
|
spectatorCount: room.spectators.size,
|
||||||
status: room.status,
|
status: room.status,
|
||||||
|
betAmount: room.betAmount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return summaries;
|
return summaries;
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export interface Room {
|
|||||||
status: "waiting" | "playing" | "finished";
|
status: "waiting" | "playing" | "finished";
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
options?: Record<string, unknown>;
|
options?: Record<string, unknown>;
|
||||||
|
betAmount: number;
|
||||||
|
/** Guard against double bet-deduction when two joins race */
|
||||||
|
betsPending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomSummary {
|
export interface RoomSummary {
|
||||||
@@ -21,6 +24,7 @@ export interface RoomSummary {
|
|||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
spectatorCount: number;
|
spectatorCount: number;
|
||||||
status: "waiting" | "playing" | "finished";
|
status: "waiting" | "playing" | "finished";
|
||||||
|
betAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerInfo {
|
export interface PlayerInfo {
|
||||||
@@ -51,8 +55,8 @@ export type GameWsServerMessage =
|
|||||||
| { type: "PLAYER_JOINED"; roomId: string; player: PlayerInfo; joinedAs: "player" | "spectator" }
|
| { type: "PLAYER_JOINED"; roomId: string; player: PlayerInfo; joinedAs: "player" | "spectator" }
|
||||||
| { type: "PLAYER_LEFT"; roomId: string; playerId: string }
|
| { type: "PLAYER_LEFT"; roomId: string; playerId: string }
|
||||||
| { type: "GAME_STARTED"; roomId: string; state: unknown }
|
| { 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: "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: "SESSION_REPLACED"; roomId: string }
|
||||||
| { type: "ERROR"; message: string };
|
| { type: "ERROR"; message: string };
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface RoomSummary {
|
|||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
spectatorCount: number;
|
spectatorCount: number;
|
||||||
status: "waiting" | "playing" | "finished";
|
status: "waiting" | "playing" | "finished";
|
||||||
|
betAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHESS_TIME_CONTROLS = [
|
const CHESS_TIME_CONTROLS = [
|
||||||
@@ -37,6 +38,8 @@ export function GameLobby() {
|
|||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [configGame, setConfigGame] = useState<string | null>(null);
|
const [configGame, setConfigGame] = useState<string | null>(null);
|
||||||
const [chessTimeControl, setChessTimeControl] = useState("blitz_5_3");
|
const [chessTimeControl, setChessTimeControl] = useState("blitz_5_3");
|
||||||
|
const [soloMode, setSoloMode] = useState(false);
|
||||||
|
const [betAmount, setBetAmount] = useState(0);
|
||||||
|
|
||||||
const gameTypes = gameUIRegistry.list();
|
const gameTypes = gameUIRegistry.list();
|
||||||
|
|
||||||
@@ -61,6 +64,8 @@ export function GameLobby() {
|
|||||||
function handleGameSelect(gameSlug: string) {
|
function handleGameSelect(gameSlug: string) {
|
||||||
if (gameSlug === "chess") {
|
if (gameSlug === "chess") {
|
||||||
setConfigGame("chess");
|
setConfigGame("chess");
|
||||||
|
setSoloMode(false);
|
||||||
|
setBetAmount(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
send({ type: "CREATE_ROOM", gameType: gameSlug });
|
send({ type: "CREATE_ROOM", gameType: gameSlug });
|
||||||
@@ -68,9 +73,19 @@ export function GameLobby() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createChessRoom() {
|
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);
|
setShowCreate(false);
|
||||||
setConfigGame(null);
|
setConfigGame(null);
|
||||||
|
setSoloMode(false);
|
||||||
|
setBetAmount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -138,6 +153,11 @@ export function GameLobby() {
|
|||||||
{room.status === "waiting" ? "Waiting" : "Playing"}
|
{room.status === "waiting" ? "Waiting" : "Playing"}
|
||||||
</span>
|
</span>
|
||||||
<span>{room.playerCount}/{room.maxPlayers} players</span>
|
<span>{room.playerCount}/{room.maxPlayers} players</span>
|
||||||
|
{room.betAmount > 0 && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-warning/15 text-warning">
|
||||||
|
{room.betAmount} AU
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{room.spectatorCount > 0 && <span>· 👁 {room.spectatorCount}</span>}
|
{room.spectatorCount > 0 && <span>· 👁 {room.spectatorCount}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,11 +223,49 @@ export function GameLobby() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Solo Mode Toggle */}
|
||||||
|
<div className="mt-4 flex items-center justify-between rounded-xl bg-raised px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">Solo Play</div>
|
||||||
|
<div className="text-[11px] text-text-tertiary">Play both sides yourself</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSoloMode(s => !s); if (!soloMode) setBetAmount(0); }}
|
||||||
|
className={`relative w-10 h-6 rounded-full transition-colors ${soloMode ? "bg-primary" : "bg-surface"}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${soloMode ? "translate-x-4" : "translate-x-0"}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bet Amount */}
|
||||||
|
<div className={`mt-3 ${soloMode ? "opacity-40 pointer-events-none" : ""}`}>
|
||||||
|
<div className="text-[10px] font-label font-semibold text-text-disabled uppercase tracking-wider mb-1.5">
|
||||||
|
Wager (AU)
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{[0, 10, 25, 50, 100, 250, 500].map(amt => (
|
||||||
|
<button
|
||||||
|
key={amt}
|
||||||
|
onClick={() => setBetAmount(amt)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
betAmount === amt
|
||||||
|
? amt === 0
|
||||||
|
? "bg-raised text-foreground ring-1 ring-text-tertiary/30"
|
||||||
|
: "bg-warning/15 text-warning ring-1 ring-warning/30"
|
||||||
|
: "bg-raised text-text-tertiary hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{amt === 0 ? "Free" : `${amt}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={createChessRoom}
|
onClick={createChessRoom}
|
||||||
className="mt-5 w-full rounded-xl bg-primary text-on-primary px-4 py-2.5 text-sm font-label font-semibold hover:opacity-90 transition-colors"
|
className="mt-5 w-full rounded-xl bg-primary text-on-primary px-4 py-2.5 text-sm font-label font-semibold hover:opacity-90 transition-colors"
|
||||||
>
|
>
|
||||||
Create Room
|
{soloMode ? "Start Solo Game" : betAmount > 0 ? `Create Room · ${betAmount} AU` : "Create Room"}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
gameState, players, spectators, roomStatus,
|
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);
|
} = useGameRoom(roomId!, userId, role, preferAs);
|
||||||
|
|
||||||
|
const betAmount = roomOptions.betAmount ?? 0;
|
||||||
|
|
||||||
const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined;
|
const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined;
|
||||||
|
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
@@ -100,6 +102,11 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
{roomStatus === "waiting" ? "Waiting" : roomStatus === "playing" ? "Playing" : "Finished"}
|
{roomStatus === "waiting" ? "Waiting" : roomStatus === "playing" ? "Playing" : "Finished"}
|
||||||
</span>
|
</span>
|
||||||
{isSpectator && <span className="text-text-disabled">Spectating</span>}
|
{isSpectator && <span className="text-text-disabled">Spectating</span>}
|
||||||
|
{betAmount > 0 && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-warning/15 text-warning">
|
||||||
|
{betAmount * 2} AU pot
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span>👁 {spectators.length}</span>
|
<span>👁 {spectators.length}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +146,14 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
? `Winner: ${players.find(p => p.discordId === gameOver.winner)?.username ?? gameOver.winner}`
|
? `Winner: ${players.find(p => p.discordId === gameOver.winner)?.username ?? gameOver.winner}`
|
||||||
: "Draw!"}
|
: "Draw!"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-tertiary mt-1">Reason: {gameOver.reason}</div>
|
<div className="text-xs text-text-tertiary mt-1">{gameOver.reason}</div>
|
||||||
|
{gameOver.payout && (
|
||||||
|
<div className="text-xs font-semibold text-warning mt-1.5">
|
||||||
|
{gameOver.payout.refunded
|
||||||
|
? `Wager refunded: ${gameOver.payout.amount} AU`
|
||||||
|
: `Payout: ${gameOver.payout.amount} AU`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ export function ChessGame({ state, myPlayerId, isSpectator, onAction, players }:
|
|||||||
// Resolve player names
|
// Resolve player names
|
||||||
const getPlayerName = (color: "white" | "black") => {
|
const getPlayerName = (color: "white" | "black") => {
|
||||||
if (isPlayerView(state)) {
|
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;
|
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");
|
return players.find(p => p.discordId === id)?.username ?? (color === myColor ? "You" : "Opponent");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ interface GameRoomState {
|
|||||||
spectators: PlayerInfo[];
|
spectators: PlayerInfo[];
|
||||||
roomStatus: "connecting" | "waiting" | "playing" | "finished" | "not_found";
|
roomStatus: "connecting" | "waiting" | "playing" | "finished" | "not_found";
|
||||||
isSpectator: boolean;
|
isSpectator: boolean;
|
||||||
gameOver: { winner: string | null; reason: string } | null;
|
gameOver: { winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } } | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
sessionReplaced: boolean;
|
sessionReplaced: boolean;
|
||||||
|
roomOptions: { betAmount?: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGameRoom(roomId: string, userId: string, role?: string, preferAs: "player" | "spectator" = "player") {
|
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,
|
gameOver: null,
|
||||||
error: null,
|
error: null,
|
||||||
sessionReplaced: false,
|
sessionReplaced: false,
|
||||||
|
roomOptions: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,6 +58,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
|
|||||||
players: msg.players ?? prev.players,
|
players: msg.players ?? prev.players,
|
||||||
spectators: msg.spectators ?? prev.spectators,
|
spectators: msg.spectators ?? prev.spectators,
|
||||||
gameState: msg.state !== undefined ? msg.state : prev.gameState,
|
gameState: msg.state !== undefined ? msg.state : prev.gameState,
|
||||||
|
roomOptions: msg.roomOptions ?? prev.roomOptions,
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -117,7 +120,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
|
|||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
roomStatus: "finished",
|
roomStatus: "finished",
|
||||||
gameOver: { winner: msg.winner, reason: msg.reason },
|
gameOver: { winner: msg.winner, reason: msg.reason, payout: msg.payout },
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export enum TransactionType {
|
|||||||
QUEST_REWARD = 'QUEST_REWARD',
|
QUEST_REWARD = 'QUEST_REWARD',
|
||||||
TRIVIA_ENTRY = 'TRIVIA_ENTRY',
|
TRIVIA_ENTRY = 'TRIVIA_ENTRY',
|
||||||
TRIVIA_WIN = 'TRIVIA_WIN',
|
TRIVIA_WIN = 'TRIVIA_WIN',
|
||||||
|
GAME_BET = 'GAME_BET',
|
||||||
|
GAME_WIN = 'GAME_WIN',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ItemTransactionType {
|
export enum ItemTransactionType {
|
||||||
|
|||||||
Reference in New Issue
Block a user