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:
@@ -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<string | null>(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"}
|
||||
</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>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,11 +223,49 @@ export function GameLobby() {
|
||||
})}
|
||||
</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
|
||||
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"
|
||||
>
|
||||
Create Room
|
||||
{soloMode ? "Start Solo Game" : betAmount > 0 ? `Create Room · ${betAmount} AU` : "Create Room"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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"}
|
||||
</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>
|
||||
</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}`
|
||||
: "Draw!"}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user