+
+
+ {/* Header */}
+
+ {status === 'processing' &&
}
+ {status === 'success' &&
}
+ {status === 'error' &&
}
+
+
+
+ {status === 'error' ? t('progressOverlay.operationFailed') : title}
+
+ {status === 'processing' && (
+
+
{t('progressOverlay.processing')} {progress !== undefined ? `${Math.round(progress)}%` : ''}
+ {progress !== undefined && (
+
+ )}
+
+ )}
+ {status === 'success' &&
+
{t('progressOverlay.complete')}
}
+ {status === 'error' &&
+
{error || t('progressOverlay.unknownError')}
}
+
+
+
+ {/* Content Area */}
+
+ {children ? (
+
+ {children}
+
+ ) : (
+
+
+ {logs.map((log, index) => (
+
+ {'>'}
+
+ {log}
+
+
+ ))}
+ {status === 'processing' && (
+
+ {'>'}
+ _
+
+ )}
+
+
+
+ )}
+
+
+ {/* Footer - Show OK button for success or error */}
+ {(status === 'success' || status === 'error') && (
+
+
+ {t('progressOverlay.ok')}
+
+
+ )}
+
+
+ );
+};
diff --git a/src/dashboard/src/components/ui/RoleIcon.tsx b/src/dashboard/src/components/ui/RoleIcon.tsx
new file mode 100644
index 0000000..5a0871c
--- /dev/null
+++ b/src/dashboard/src/components/ui/RoleIcon.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import {Eye, Moon, Shield, Swords, Users, Zap} from 'lucide-react';
+import {Role} from '@/types';
+
+export const RoleIcon = ({role}: { role: Role }) => {
+ switch (role) {
+ case 'WEREWOLF':
+ return
;
+ case 'SEER':
+ return
;
+ case 'WITCH':
+ return
;
+ case 'HUNTER':
+ return
;
+ case 'GUARD':
+ return
;
+ case 'VILLAGER':
+ return
;
+ default:
+ return
;
+ }
+};
diff --git a/src/dashboard/src/components/ui/ThemeToggle.tsx b/src/dashboard/src/components/ui/ThemeToggle.tsx
new file mode 100644
index 0000000..fdb42f6
--- /dev/null
+++ b/src/dashboard/src/components/ui/ThemeToggle.tsx
@@ -0,0 +1,22 @@
+import {Moon, Sun} from 'lucide-react';
+import {useTheme} from '@/lib/ThemeProvider';
+import {useTranslation} from '@/lib/i18n';
+
+export const ThemeToggle: React.FC = () => {
+ const {theme, toggleTheme} = useTheme();
+ const {t} = useTranslation();
+
+ return (
+
+ {theme === 'dark' ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/src/dashboard/src/features/auth/components/AccessDenied.tsx b/src/dashboard/src/features/auth/components/AccessDenied.tsx
new file mode 100644
index 0000000..935fd80
--- /dev/null
+++ b/src/dashboard/src/features/auth/components/AccessDenied.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import {ArrowLeft, ShieldAlert} from 'lucide-react';
+import {useNavigate} from 'react-router-dom';
+import {useTranslation} from '@/lib/i18n';
+
+export const AccessDenied: React.FC = () => {
+ const {t} = useTranslation();
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+
+ {t('accessDenied.title')}
+
+
+ {t('accessDenied.message')}
+
+
+
+
+ {t('accessDenied.suggestion')}
+
+
+
navigate('/')}
+ className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-slate-200 dark:bg-slate-800 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-lg transition-colors font-medium"
+ >
+
+ {t('accessDenied.back')}
+
+
+
+ );
+};
diff --git a/src/dashboard/src/features/auth/components/AuthCallback.tsx b/src/dashboard/src/features/auth/components/AuthCallback.tsx
new file mode 100644
index 0000000..f1f6b66
--- /dev/null
+++ b/src/dashboard/src/features/auth/components/AuthCallback.tsx
@@ -0,0 +1,44 @@
+import {useEffect} from 'react';
+import {useNavigate} from 'react-router-dom';
+import {Loader2} from 'lucide-react';
+import {useAuth} from '@/features/auth/contexts/AuthContext';
+import {useTranslation} from '@/lib/i18n';
+
+export const AuthCallback = () => {
+ const navigate = useNavigate();
+ const {checkAuth} = useAuth();
+ const {t} = useTranslation();
+
+ useEffect(() => {
+ const handleCallback = async () => {
+ // Wait for a moment to let cookies settle
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ // Re-check auth to get the new session
+ await checkAuth();
+
+ // Get the guild ID from the URL parameters
+ const params = new URLSearchParams(window.location.search);
+ const guildId = params.get('guild_id');
+
+ if (guildId) {
+ // Redirect to the server dashboard
+ navigate(`/server/${guildId}`);
+ } else {
+ // If no guild ID, redirect to server selector
+ navigate('/');
+ }
+ };
+
+ handleCallback();
+ }, [navigate, checkAuth]);
+
+ return (
+
+
+
+
{t('auth.loggingIn')}
+
+
+ );
+};
diff --git a/src/dashboard/src/features/auth/components/LoginScreen.tsx b/src/dashboard/src/features/auth/components/LoginScreen.tsx
new file mode 100644
index 0000000..0f28bf1
--- /dev/null
+++ b/src/dashboard/src/features/auth/components/LoginScreen.tsx
@@ -0,0 +1,51 @@
+import {Moon} from 'lucide-react';
+import {useTranslation} from '@/lib/i18n';
+
+interface LoginScreenProps {
+ onLogin: () => void;
+}
+
+export const LoginScreen: React.FC
= ({onLogin}) => {
+ const {t} = useTranslation();
+
+ return (
+
+ {/* Background Effects */}
+
+
+
+
+
+
+
+
{t('login.title')}
+
{t('login.subtitle')}
+
+
+
+
+
+
+
+ {t('login.loginButton')}
+
+
+ {t('login.restriction')}
+
+
+
+
+ );
+};
diff --git a/src/dashboard/src/features/auth/components/SessionExpiredModal.tsx b/src/dashboard/src/features/auth/components/SessionExpiredModal.tsx
new file mode 100644
index 0000000..76bd3e6
--- /dev/null
+++ b/src/dashboard/src/features/auth/components/SessionExpiredModal.tsx
@@ -0,0 +1,43 @@
+import {LogOut} from 'lucide-react';
+import {useTranslation} from '@/lib/i18n';
+
+interface SessionExpiredModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export const SessionExpiredModal = ({isOpen, onClose}: SessionExpiredModalProps) => {
+ const {t} = useTranslation();
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ {t('auth.sessionExpiredTitle') || "Session Expired"}
+
+
+
+ {t('auth.sessionExpiredMessage') || "Your session has expired. Please log in again."}
+
+
+
+ {t('auth.loginAgain') || "Login Again"}
+
+
+
+
+ );
+};
diff --git a/src/dashboard/src/features/auth/contexts/AuthContext.tsx b/src/dashboard/src/features/auth/contexts/AuthContext.tsx
new file mode 100644
index 0000000..21cc2ee
--- /dev/null
+++ b/src/dashboard/src/features/auth/contexts/AuthContext.tsx
@@ -0,0 +1,88 @@
+import React, {createContext, ReactNode, useContext, useEffect, useState} from 'react';
+import {User} from '@/types';
+
+interface AuthContextType {
+ user: User | null;
+ loading: boolean;
+ login: (guildId: string) => void;
+ logout: () => Promise;
+ checkAuth: () => Promise;
+}
+
+const AuthContext = createContext(undefined);
+
+export const useAuth = () => {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error('useAuth must be used within AuthProvider');
+ }
+ return context;
+};
+
+interface AuthProviderProps {
+ children: ReactNode;
+}
+
+export const AuthProvider: React.FC = ({children}) => {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const checkAuth = async () => {
+ try {
+ const response = await fetch('/api/auth/me', {
+ credentials: 'include'
+ });
+
+ // Check if the response is JSON
+ const contentType = response.headers.get('content-type');
+ if (!contentType || !contentType.includes('application/json')) {
+ // Backend is not running or returned HTML
+ console.warn('Backend not responding with JSON, user not authenticated');
+ setUser(null);
+ return;
+ }
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.success) {
+ setUser(data.user);
+ } else {
+ setUser(null);
+ }
+ } else {
+ setUser(null);
+ }
+ } catch (error) {
+ console.error('Auth check failed:', error);
+ setUser(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const login = (guildId: string) => {
+ window.location.href = `/api/auth/login?guild_id=${guildId}`;
+ };
+
+ const logout = async () => {
+ try {
+ await fetch('/api/auth/logout', {
+ method: 'POST',
+ credentials: 'include'
+ });
+ setUser(null);
+ } catch (error) {
+ console.error('Logout failed:', error);
+ }
+ };
+
+ useEffect(() => {
+ checkAuth();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/dashboard/src/features/auth/hooks/useDashboardAuth.ts b/src/dashboard/src/features/auth/hooks/useDashboardAuth.ts
new file mode 100644
index 0000000..1ee6515
--- /dev/null
+++ b/src/dashboard/src/features/auth/hooks/useDashboardAuth.ts
@@ -0,0 +1,119 @@
+import {useEffect, useRef} from 'react';
+import {useNavigate} from 'react-router-dom';
+import {Player, User} from '@/types';
+
+export const useDashboardAuth = (
+ guildId: string | undefined,
+ user: User | null,
+ loading: boolean,
+ checkAuth: () => Promise,
+ gameStatePlayers: Player[]
+) => {
+ const navigate = useNavigate();
+ const isSelectingGuild = useRef(false);
+
+ // Check authentication and authorization
+ useEffect(() => {
+ if (loading) return;
+
+ if (!user) {
+ navigate('/login');
+ return;
+ }
+
+ // PENDING users haven't selected a server yet, skip initial check
+ if (user.role === 'PENDING') {
+ // If they're trying to access a specific server, update their role
+ if (guildId && !isSelectingGuild.current) {
+ isSelectingGuild.current = true;
+ const selectGuild = async () => {
+ try {
+ const response = await fetch(`/api/auth/select-guild/${guildId}`, {
+ method: 'POST',
+ credentials: 'include',
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.success) {
+ // Refresh auth state to get updated role
+ await checkAuth();
+ }
+ }
+ } catch (error) {
+ console.error('Failed to select guild:', error);
+ } finally {
+ isSelectingGuild.current = false;
+ }
+ };
+ selectGuild();
+ }
+ return;
+ }
+
+ // For non-PENDING users, check if they're accessing a different guild
+ if (guildId && user.guildId && user.guildId.toString() !== guildId.toString() && !isSelectingGuild.current) {
+ // Allow switching guilds by calling select-guild
+ isSelectingGuild.current = true;
+ const switchGuild = async () => {
+ try {
+ const response = await fetch(`/api/auth/select-guild/${guildId}`, {
+ method: 'POST',
+ credentials: 'include',
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.success) {
+ // Refresh auth state to get updated role for new guild
+ await checkAuth();
+ }
+ }
+ } catch (error) {
+ console.error('Failed to switch guild:', error);
+ alert('Failed to switch server. Please try again.');
+ } finally {
+ isSelectingGuild.current = false;
+ }
+ };
+ switchGuild();
+ return;
+ }
+
+ // After ensuring we are on the correct guild, check if the user is BLOCKED
+ if (user.role === 'BLOCKED') {
+ if (!window.location.pathname.includes('/access-denied')) {
+ navigate('/access-denied');
+ }
+ return;
+ }
+
+ // Role-based route redirection for already authorized users
+ if (user.role === 'SPECTATOR') {
+ const path = window.location.pathname;
+ const baseUrl = `/server/${guildId}`;
+ if (path === baseUrl || path === `${baseUrl}/` || path.includes('/settings')) {
+ navigate(`${baseUrl}/spectator`);
+ }
+ }
+ }, [user, loading, guildId, navigate, checkAuth]);
+
+ // Security check: Lock dashboard if player has assigned roles and is not a privileged user
+ useEffect(() => {
+ if (!user || loading) return;
+
+ // Privileged roles are exempt
+ if (user.role === 'JUDGE' || user.role === 'SPECTATOR') return;
+
+ // Check if current user has any in-game roles
+ const currentPlayer = gameStatePlayers.find(p => p.userId === user.userId);
+
+ // If player exists and has roles assigned, lock them out
+ if (currentPlayer && currentPlayer.roles && currentPlayer.roles.length > 0) {
+ navigate('/access-denied');
+ }
+ }, [user, loading, gameStatePlayers, navigate]);
+
+ // Return if the guild is ready (user matches guild)
+ return user && user.guildId && user.guildId.toString() === guildId;
+};
diff --git a/src/dashboard/src/features/auth/pages/LoginPage.tsx b/src/dashboard/src/features/auth/pages/LoginPage.tsx
new file mode 100644
index 0000000..ad09aad
--- /dev/null
+++ b/src/dashboard/src/features/auth/pages/LoginPage.tsx
@@ -0,0 +1,8 @@
+import {LoginScreen} from '../components/LoginScreen';
+
+export const LoginPage = () => {
+ const handleLogin = () => {
+ window.location.href = '/api/auth/login';
+ };
+ return ;
+};
diff --git a/src/dashboard/src/features/game/components/Dashboard.tsx b/src/dashboard/src/features/game/components/Dashboard.tsx
new file mode 100644
index 0000000..05e3a79
--- /dev/null
+++ b/src/dashboard/src/features/game/components/Dashboard.tsx
@@ -0,0 +1,298 @@
+import {useEffect, useState} from 'react';
+import {Route, Routes, useNavigate, useParams} from 'react-router-dom';
+import {MessageSquare, Users, X} from 'lucide-react';
+import {useTranslation} from '@/lib/i18n';
+import {useAuth} from '@/features/auth/contexts/AuthContext';
+
+// Hooks
+import {useGameState} from '../hooks/useGameState';
+import {useGameActions} from '../hooks/useGameActions';
+import {useDashboardAuth} from '@/features/auth/hooks/useDashboardAuth';
+
+// Components
+import {Sidebar} from '@/components/layout/Sidebar';
+import {GameHeader} from './GameHeader';
+import {GameLog} from './GameLog';
+import {PlayerCard} from '@/features/players/components/PlayerCard';
+import {SpectatorView} from '@/features/spectator/components/SpectatorView';
+import {SpeechManager} from '@/features/speech/components/SpeechManager';
+import {GameSettingsPage} from './GameSettingsPage';
+import {PlayerEditModal} from '@/features/players/components/PlayerEditModal';
+import {DeathConfirmModal} from './DeathConfirmModal';
+import {ProgressOverlay} from '@/components/ui/ProgressOverlay';
+import {VoteStatus} from './VoteStatus';
+import {TimerControlModal} from './TimerControlModal';
+import {PlayerSelectModal} from '@/features/players/components/PlayerSelectModal';
+import {SessionExpiredModal} from '@/features/auth/components/SessionExpiredModal';
+import {SettingsModal} from './SettingsModal';
+
+export const Dashboard = () => {
+ const {guildId} = useParams<{ guildId: string }>();
+ const navigate = useNavigate();
+ const {t} = useTranslation();
+ const {user, loading, logout, checkAuth} = useAuth();
+
+ // Local UI State
+ const [showSettings, setShowSettings] = useState(false);
+ const [showLogs, setShowLogs] = useState(false);
+ const [lastSeenLogCount, setLastSeenLogCount] = useState(0);
+ const [isSpectatorSimulation, setIsSpectatorSimulation] = useState(false);
+
+ // Business Logic Hooks
+ const {
+ gameState,
+ setGameState,
+ isConnected,
+ overlayState,
+ setOverlayState,
+ showSessionExpired,
+ setShowSessionExpired
+ } = useGameState(guildId, user);
+
+ const {
+ handleAction,
+ handleGlobalAction,
+ handleTimerStart,
+ handlePlayerSelect,
+ showTimerModal,
+ setShowTimerModal,
+ editingPlayerId,
+ setEditingPlayerId,
+ deathConfirmPlayerId,
+ setDeathConfirmPlayerId,
+ playerSelectModal,
+ setPlayerSelectModal
+ } = useGameActions(guildId, gameState, setGameState, setOverlayState);
+
+ const isGuildReady = useDashboardAuth(guildId, user, loading, checkAuth, gameState.players);
+
+ // Editing player helper
+ const editingPlayer = gameState.players.find(p => p.id === editingPlayerId);
+
+ const toggleSpectatorSimulation = () => {
+ const newMode = !isSpectatorSimulation;
+ setIsSpectatorSimulation(newMode);
+ if (newMode) {
+ navigate(`/server/${guildId}/spectator`);
+ } else {
+ navigate(`/server/${guildId}`);
+ }
+ };
+
+ const toggleLogs = () => {
+ const newShowLogs = !showLogs;
+ setShowLogs(newShowLogs);
+ if (newShowLogs) {
+ setLastSeenLogCount(gameState.logs.length);
+ }
+ };
+
+ // Update log count if logs change while open
+ useEffect(() => {
+ if (showLogs) {
+ setLastSeenLogCount(gameState.logs.length);
+ }
+ }, [gameState.logs.length, showLogs]);
+
+ return (
+
+
navigate(`/server/${guildId}/settings`)}
+ onDashboardClick={() => navigate(`/server/${guildId}`)}
+ onSpectatorClick={() => navigate(`/server/${guildId}/spectator`)}
+ onSpeechClick={() => navigate(`/server/${guildId}/speech`)}
+ onSwitchServer={() => navigate('/')}
+ onToggleSpectatorMode={toggleSpectatorSimulation}
+ isSpectatorMode={isSpectatorSimulation}
+ isConnected={isConnected}
+ />
+
+
+
+
+
+ {isGuildReady ? (
+ <>
+
+
+
+
+
+ {t('players.title')}
+ ({gameState.players.filter(p => p.isAlive).length} {t('players.alive')})
+
+
+
+
+ {gameState.players.map(player => (
+
+ ))}
+
+ >
+ }/>
+
+ }/>
+
+ }/>
+
+ }/>
+
+ >
+ ) : (
+
+
+
+
{t('serverSelector.switching')}
+
+
+ )}
+
+
+
+
+ {isGuildReady && (
+ <>
+
+ {showLogs ? : }
+ {!showLogs && gameState.logs.length > lastSeenLogCount && (
+
+ )}
+
+
+ {showLogs && (
+
+
+
+ )}
+ >
+ )}
+
+ {showSettings && setShowSettings(false)}/>}
+
+ {editingPlayerId && editingPlayer && guildId && (
+ setEditingPlayerId(null)}
+ doubleIdentities={gameState.doubleIdentities}
+ availableRoles={gameState.availableRoles || []}
+ />
+ )}
+
+ {deathConfirmPlayerId && guildId && (
+ p.id === deathConfirmPlayerId)!}
+ guildId={guildId}
+ onClose={() => setDeathConfirmPlayerId(null)}
+ />
+ )}
+
+ setOverlayState(prev => ({...prev, visible: false}))}
+ />
+
+
+ p.isAlive).length}
+ endTime={undefined}
+ players={gameState.players}
+ title={t('vote.expelVote')}
+ />
+
+
+ {showTimerModal && (
+ setShowTimerModal(false)}
+ onStart={handleTimerStart}
+ />
+ )}
+
+ {
+ setShowSessionExpired(false);
+ window.location.href = '/api/auth/login';
+ }}
+ />
+
+ {playerSelectModal.visible && (
+ setPlayerSelectModal({
+ ...playerSelectModal,
+ visible: false,
+ customPlayers: undefined
+ })}
+ onSelect={handlePlayerSelect}
+ filter={(p) => {
+ if (!p.userId) return false;
+ if (playerSelectModal.type === 'ASSIGN_JUDGE') return !p.isJudge;
+ if (playerSelectModal.type === 'DEMOTE_JUDGE') return !!p.isJudge;
+ if (playerSelectModal.type === 'FORCE_POLICE') return p.isAlive;
+ return true;
+ }}
+ />
+ )}
+
+
+ );
+};
diff --git a/src/dashboard/src/features/game/components/DeathConfirmModal.tsx b/src/dashboard/src/features/game/components/DeathConfirmModal.tsx
new file mode 100644
index 0000000..71c4d8d
--- /dev/null
+++ b/src/dashboard/src/features/game/components/DeathConfirmModal.tsx
@@ -0,0 +1,104 @@
+import React, {useState} from 'react';
+import {useTranslation} from '@/lib/i18n';
+import {Player} from '@/types';
+import {Skull, X} from 'lucide-react';
+import {api} from '@/lib/api';
+
+interface DeathConfirmModalProps {
+ player: Player;
+ guildId: string;
+ onClose: () => void;
+}
+
+export const DeathConfirmModal: React.FC = ({player, guildId, onClose}) => {
+ const {t} = useTranslation();
+ const [lastWords, setLastWords] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ const handleConfirm = async () => {
+ setLoading(true);
+ try {
+ if (player.userId) {
+ await api.markPlayerDead(guildId, player.userId, lastWords);
+ onClose();
+ }
+ } catch (error) {
+ console.error('Failed to mark player as dead:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t('actions.kill')}
+
+
+
+
+
+
+
+
+
+ {player.avatar ?
: '👤'}
+
+
+
{player.name}
+
+ {player.roles.join(', ') || t('roles.unknown')}
+
+
+
+
+
+
{t('players.killConfirmation', 'Are you sure you want to kill this player?')}
+
+
+
+ setLastWords(e.target.checked)}
+ className="w-4 h-4 text-red-600 rounded border-slate-300 focus:ring-red-500"
+ />
+
+ {t('game.lastWords', 'Allow Last Words')}
+
+
+
+
+
+ {t('common.cancel')}
+
+
+ {loading ? '...' : (
+ <>
+
+ {t('actions.kill')}
+ >
+ )}
+
+
+
+
+
+ );
+};
diff --git a/src/dashboard/src/features/game/components/GameHeader.tsx b/src/dashboard/src/features/game/components/GameHeader.tsx
new file mode 100644
index 0000000..8210490
--- /dev/null
+++ b/src/dashboard/src/features/game/components/GameHeader.tsx
@@ -0,0 +1,112 @@
+import {Link} from 'react-router-dom';
+import {Mic, Moon, Pause, Play, SkipForward, Sun} from 'lucide-react';
+import {GamePhase, Player, SpeechState} from '@/types';
+import {useTranslation} from '@/lib/i18n';
+
+interface GameHeaderProps {
+ phase: GamePhase;
+ dayCount: number;
+ timerSeconds: number;
+ onGlobalAction: (action: string) => void;
+ speech?: SpeechState;
+ players?: Player[];
+ readonly?: boolean;
+}
+
+export const GameHeader: React.FC = ({
+ phase,
+ dayCount,
+ timerSeconds,
+ onGlobalAction,
+ speech,
+ players,
+ readonly = false
+ }) => {
+ const {t} = useTranslation();
+ const btnStyle = "px-4 py-2 rounded-lg font-medium transition-all active:scale-95 flex items-center justify-center gap-2";
+ const btnPrimary = "bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-900/20 dark:shadow-indigo-900/20";
+ const btnSecondary = "bg-slate-300 dark:bg-slate-700 hover:bg-slate-400 dark:hover:bg-slate-600 text-slate-800 dark:text-slate-200";
+
+ const currentSpeaker = speech?.currentSpeakerId && players
+ ? players.find(p => p.id === speech.currentSpeakerId)
+ : null;
+
+ return (
+
+
+
+
{t('gameHeader.currentPhase')}
+
+ {phase === 'DAY' ? :
+ }
+ {t(`phases.${phase}`)} {dayCount > 0 && `#${dayCount}`}
+
+
+
+ {currentSpeaker && (
+ <>
+
+
+
+ {t('messages.speaking')}
+
+
+ {currentSpeaker.name}
+ {speech?.endTime && (
+
+ {(() => {
+ const seconds = Math.max(0, Math.ceil((speech.endTime - Date.now()) / 1000));
+ return `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
+ })()}
+
+ )}
+
+
+ >
+ )}
+
+
+
+
+
{t('gameHeader.timer')}
+
+ {Math.floor(timerSeconds / 60)}:{String(timerSeconds % 60).padStart(2, '0')}
+
+
+
+
+
+ {!readonly && (
+ phase === 'LOBBY' ? (
+
onGlobalAction('start_game')}
+ className={`${btnStyle} ${btnPrimary}`}
+ >
+ {t('gameHeader.startGame')}
+
+ ) : (
+ <>
+
onGlobalAction('pause')} className={`${btnStyle} ${btnSecondary}`}>
+
+
+
onGlobalAction('next_phase')}
+ className={`${btnStyle} ${btnSecondary}`}
+ >
+ {t('gameHeader.nextPhase')}
+
+ >
+ )
+ )}
+
+
+ );
+};
diff --git a/src/dashboard/src/features/game/components/GameLog.tsx b/src/dashboard/src/features/game/components/GameLog.tsx
new file mode 100644
index 0000000..430aa0f
--- /dev/null
+++ b/src/dashboard/src/features/game/components/GameLog.tsx
@@ -0,0 +1,118 @@
+import {useState} from 'react';
+import {AlertTriangle, MessageSquare} from 'lucide-react';
+import {LogEntry} from '@/types';
+import {useTranslation} from '@/lib/i18n';
+
+interface GameLogProps {
+ logs: LogEntry[];
+ onGlobalAction: (action: string) => void;
+ readonly?: boolean;
+ className?: string;
+}
+
+export const GameLog: React.FC = ({logs, onGlobalAction, readonly = false, className = ""}) => {
+ const {t} = useTranslation();
+ const [resetConfirming, setResetConfirming] = useState(false);
+
+ return (
+
+
+
+ {t('gameLog.title')}
+
+
+
+
+ {logs.map(log => (
+
+
{log.timestamp}
+
+ {log.type === 'alert' &&
}
+ {log.message}
+
+
+ ))}
+
+
+ {/* Admin Actions */}
+ {!readonly && (
+
+
+
+ {/* Game Flow */}
+
+
{t('globalCommands.gameFlow')}
+
+ onGlobalAction('random_assign')}
+ className="text-xs bg-slate-200 dark:bg-slate-800 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 px-2 py-2 rounded border border-slate-400 dark:border-slate-700 truncate"
+ title={t('globalCommands.randomAssign')}>{t('globalCommands.randomAssign')}
+ onGlobalAction('start_game')}
+ className="text-xs bg-blue-100 dark:bg-blue-900/20 hover:bg-blue-200 dark:hover:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-2 rounded border border-blue-300 dark:border-blue-900/30 truncate"
+ title={t('globalCommands.startGame')}>{t('globalCommands.startGame')}
+ {
+ if (resetConfirming) {
+ onGlobalAction('reset');
+ setResetConfirming(false);
+ } else {
+ setResetConfirming(true);
+ setTimeout(() => setResetConfirming(false), 3000);
+ }
+ }}
+ className={`text-xs px-2 py-2 rounded border truncate transition-all duration-200 ${resetConfirming
+ ? 'bg-red-600 text-white border-red-700 hover:bg-red-700 font-bold'
+ : 'bg-red-100 dark:bg-red-900/20 hover:bg-red-200 dark:hover:bg-red-900/40 text-red-700 dark:text-red-300 border-red-300 dark:border-red-900/30'
+ }`}
+ title={t('globalCommands.forceReset')}
+ >
+ {resetConfirming ? t('globalCommands.confirmReset') : t('globalCommands.forceReset')}
+
+
+
+
+ {/* Voice & Timer */}
+
+
{t('globalCommands.voiceTimer')}
+
+ onGlobalAction('timer_start')}
+ className="text-xs bg-amber-100 dark:bg-amber-900/20 hover:bg-amber-200 dark:hover:bg-amber-900/40 text-amber-700 dark:text-amber-300 px-2 py-2 rounded border border-amber-300 dark:border-amber-900/30 truncate"
+ title={t('timer.start')}>{t('timer.start')}
+ onGlobalAction('mute_all')}
+ className="text-xs bg-slate-200 dark:bg-slate-800 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 px-2 py-2 rounded border border-slate-400 dark:border-slate-700 truncate"
+ title={t('voice.muteAll')}>{t('voice.muteAll')}
+ onGlobalAction('unmute_all')}
+ className="text-xs bg-slate-200 dark:bg-slate-800 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 px-2 py-2 rounded border border-slate-400 dark:border-slate-700 truncate"
+ title={t('voice.unmuteAll')}>{t('voice.unmuteAll')}
+
+
+
+ {/* Admin & Roles */}
+
+
{t('globalCommands.adminRoles')}
+
+ onGlobalAction('assign_judge')}
+ className="text-xs bg-purple-100 dark:bg-purple-900/20 hover:bg-purple-200 dark:hover:bg-purple-900/40 text-purple-700 dark:text-purple-300 px-2 py-2 rounded border border-purple-300 dark:border-purple-900/30 truncate"
+ title={t('admin.assignJudge')}>{t('admin.assignJudge')}
+ onGlobalAction('demote_judge')}
+ className="text-xs bg-purple-100 dark:bg-purple-900/20 hover:bg-purple-200 dark:hover:bg-purple-900/40 text-purple-700 dark:text-purple-300 px-2 py-2 rounded border border-purple-300 dark:border-purple-900/30 truncate"
+ title={t('admin.demoteJudge')}>{t('admin.demoteJudge')}
+ onGlobalAction('force_police')}
+ className="text-xs bg-slate-200 dark:bg-slate-800 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 px-2 py-2 rounded border border-slate-400 dark:border-slate-700 truncate"
+ title={t('admin.forcePolice')}>{t('admin.forcePolice')}
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/src/dashboard/src/features/game/components/GameSettingsPage.tsx b/src/dashboard/src/features/game/components/GameSettingsPage.tsx
new file mode 100644
index 0000000..0c35c45
--- /dev/null
+++ b/src/dashboard/src/features/game/components/GameSettingsPage.tsx
@@ -0,0 +1,364 @@
+import React, {useEffect, useRef, useState} from 'react';
+import {AlertCircle, Check, Dices, Loader2, Minus, Plus, Users} from 'lucide-react';
+import {useParams} from 'react-router-dom';
+import {useTranslation} from '@/lib/i18n';
+import {api} from '@/lib/api';
+
+export const GameSettingsPage: React.FC = () => {
+ const {guildId} = useParams<{ guildId: string }>();
+ const {t} = useTranslation();
+
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [justSaved, setJustSaved] = useState(false);
+ const [muteAfterSpeech, setMuteAfterSpeech] = useState(true);
+ const [doubleIdentities, setDoubleIdentities] = useState(false);
+ const [roles, setRoles] = useState([]);
+ const [roleCounts, setRoleCounts] = useState>({});
+
+ // New State Variables
+ const [playerCount, setPlayerCount] = useState(12);
+ const [selectedRole, setSelectedRole] = useState('');
+ const [updatingRoles, setUpdatingRoles] = useState(false);
+
+ const AVAILABLE_ROLES = [
+ "平民", "狼人", "女巫", "預言家", "獵人",
+ "守衛", "白痴", "騎士", "守墓人", "攝夢人", "魔術師",
+ "狼王", "白狼王", "狼兄", "狼弟", "隱狼", "石像鬼",
+ "惡靈騎士", "血月使者", "機械狼", "複製人"
+ ];
+
+ const isFirstLoad = useRef(true);
+
+ // Auto-save effect
+ useEffect(() => {
+ if (isFirstLoad.current) {
+ isFirstLoad.current = false;
+ return;
+ }
+
+ if (loading) return;
+
+ const saveSettings = async () => {
+ if (!guildId) return;
+ setSaving(true);
+ try {
+ await api.updateSettings(guildId, {
+ muteAfterSpeech,
+ doubleIdentities
+ });
+ } catch (e) {
+ console.error("Failed to update settings", e);
+ } finally {
+ setJustSaved(true);
+ setTimeout(() => setSaving(false), 500);
+ setTimeout(() => setJustSaved(false), 2000);
+ }
+ };
+
+ const timeoutId = setTimeout(saveSettings, 500);
+ return () => clearTimeout(timeoutId);
+ }, [muteAfterSpeech, doubleIdentities, guildId]);
+
+ const loadSettings = async () => {
+ if (!guildId) return;
+ setLoading(true);
+ isFirstLoad.current = true;
+ try {
+ const [sessionData, rolesData]: [any, any] = await Promise.all([
+ api.getSession(guildId),
+ api.getRoles(guildId)
+ ]);
+
+ setMuteAfterSpeech(sessionData.muteAfterSpeech);
+ setDoubleIdentities(sessionData.doubleIdentities);
+
+ // Set player count from current players length
+ if (Array.isArray(sessionData.players)) {
+ setPlayerCount(sessionData.players.length);
+ }
+
+ setRoles(rolesData.filter((r: unknown): r is string => typeof r === 'string') || []);
+ } catch (e) {
+ console.error("Failed to load settings", e);
+ } finally {
+ setLoading(false);
+ setTimeout(() => {
+ isFirstLoad.current = false;
+ }, 100);
+ }
+ };
+
+ useEffect(() => {
+ if (guildId) {
+ loadSettings();
+ }
+ }, [guildId]);
+
+ useEffect(() => {
+ const counts: Record = {};
+ roles.forEach(role => {
+ counts[role] = (counts[role] || 0) + 1;
+ });
+ setRoleCounts(counts);
+ }, [roles]);
+
+ const handleAddRole = async (role: string) => {
+ if (!guildId || updatingRoles) return;
+ setUpdatingRoles(true);
+ try {
+ await api.addRole(guildId, role, 1);
+ const newRoles = await api.getRoles(guildId) as string[];
+ setRoles(newRoles.filter((r: unknown): r is string => typeof r === 'string') || []);
+ } catch (e) {
+ console.error("Failed to add role", e);
+ } finally {
+ setUpdatingRoles(false);
+ }
+ };
+
+ const handleRemoveRole = async (role: string) => {
+ if (!guildId || updatingRoles) return;
+ setUpdatingRoles(true);
+ try {
+ await api.removeRole(guildId, role, 1);
+ const newRoles = await api.getRoles(guildId) as string[];
+ setRoles(newRoles.filter((r: unknown): r is string => typeof r === 'string') || []);
+ } catch (e) {
+ console.error("Failed to remove role", e);
+ } finally {
+ setUpdatingRoles(false);
+ }
+ };
+
+ const handleRandomAssign = async () => {
+ if (!guildId) return;
+ try {
+ await api.assignRoles(guildId);
+ } catch (error: any) {
+ console.error("Assign failed", error);
+ }
+ };
+
+ const handlePlayerCountUpdate = async () => {
+ if (!guildId) return;
+ try {
+ await api.setPlayerCount(guildId, playerCount);
+ loadSettings();
+ } catch (error: any) {
+ console.error("Update failed", error);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+ {/* General Settings */}
+
+
+ {t('settings.general')}
+
+
+
+
+ {t('settings.muteAfterSpeech')}
+
+ {t('settings.muteAfterSpeechDesc')}
+
+
+
+ {(saving || justSaved) && (
+
+ {saving ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ !saving && setMuteAfterSpeech(e.target.checked)}
+ disabled={saving}
+ className="sr-only peer"
+ />
+
+
+
+
+
+
+
+ {t('settings.doubleIdentities')}
+
+ {t('settings.doubleIdentitiesDesc')}
+
+
+
+ {(saving || justSaved) && (
+
+ {saving ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ !saving && setDoubleIdentities(e.target.checked)}
+ disabled={saving}
+ className="sr-only peer"
+ />
+
+
+
+
+
+
+ {/* Player Count Settings */}
+
+
+ {t('settings.playerCount')}
+
+
+
+
+ {t('settings.totalPlayers')}
+
+
setPlayerCount(parseInt(e.target.value) || 0)}
+ className="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-lg px-4 py-2 text-slate-900 dark:text-slate-200 focus:ring-2 focus:ring-indigo-500"
+ />
+
+ {t('settings.playerCountDesc')}
+
+
+
+ {t('buttons.update')}
+
+
+
+
+ {/* Roles Settings */}
+
+
+
+ {t('roles.title')}
+ {t('messages.totalCount')}: {roles.length}
+
+
+
+ {t('messages.randomAssignRoles')}
+
+
+
+ {/* Add Role Control */}
+
+
+ setSelectedRole(e.target.value)}
+ list="role-suggestions"
+ className="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-lg px-4 py-2 text-slate-900 dark:text-slate-200 focus:ring-2 focus:ring-indigo-500"
+ placeholder={t('messages.selectOrEnterRole')}
+ disabled={updatingRoles}
+ />
+
+ {AVAILABLE_ROLES.map(role => (
+
+ ))}
+
+
+
handleAddRole(selectedRole)}
+ disabled={updatingRoles}
+ className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 py-2 rounded-lg transition-colors"
+ >
+ {updatingRoles ? : }
+ {t('messages.add')}
+
+
+
+ {/* Roles List */}
+
+ {Object.entries(roleCounts).sort((a, b) => b[1] - a[1]).map(([role, count]) => (
+
+
+
+
handleRemoveRole(role)}
+ disabled={updatingRoles}
+ className="p-1 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
+ >
+
+
+
+ {count}
+
+
handleAddRole(role)}
+ disabled={updatingRoles}
+ className="p-1 text-slate-400 hover:text-green-500 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors"
+ >
+
+
+
+
+ ))}
+
+ {roles.length === 0 && (
+
+
+ {t('messages.noRolesConfigured')}
+
+ )}
+
+
+
+
+ >
+ );
+};
diff --git a/src/dashboard/src/features/game/components/SettingsModal.tsx b/src/dashboard/src/features/game/components/SettingsModal.tsx
new file mode 100644
index 0000000..be96245
--- /dev/null
+++ b/src/dashboard/src/features/game/components/SettingsModal.tsx
@@ -0,0 +1,119 @@
+import {useState} from 'react';
+import {AlertCircle, Check, Wifi, X} from 'lucide-react';
+import {useTranslation} from '@/lib/i18n';
+import {api} from '@/lib/api';
+
+interface SettingsModalProps {
+ onClose: () => void;
+}
+
+export const SettingsModal: React.FC = ({onClose}) => {
+ const {t} = useTranslation();
+ const [backendUrl, setBackendUrl] = useState(api.getConfiguredUrl());
+ const [testing, setTesting] = useState(false);
+ const [testResult, setTestResult] = useState<'success' | 'error' | null>(null);
+
+ const handleSave = async () => {
+ api.setBackendUrl(backendUrl);
+ window.location.reload(); // Reload to reconnect WebSocket and apply changes
+ };
+
+ const handleTest = async () => {
+ setTesting(true);
+ setTestResult(null);
+
+ try {
+ const tempApi = new (api.constructor as any)();
+ tempApi.setBackendUrl(backendUrl);
+ const success = await tempApi.testConnection();
+ setTestResult(success ? 'success' : 'error');
+ } catch {
+ setTestResult('error');
+ } finally {
+ setTesting(false);
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
{t('sidebar.gameSettings')}
+
+
+
+
+
+ {/* Content */}
+
+
+
+ {/* Backend URL */}
+
+
+ {t('settingsModal.backendUrl')}
+
+
setBackendUrl(e.target.value)}
+ placeholder={t('settingsModal.urlPlaceholder')}
+ className="w-full px-4 py-2 bg-slate-100 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ />
+
+ {t('settingsModal.urlHint')}
+
+
+
+ {/* Test Connection */}
+
+
+
+ {testing ? t('settingsModal.testing') : t('settingsModal.testConnection')}
+
+
+ {testResult === 'success' && (
+
+
+ {t('settingsModal.connectionSuccess')}
+
+ )}
+
+ {testResult === 'error' && (
+
+
+
{t('settingsModal.connectionFailed')}
+
+ )}
+
+
+
+ {/* Footer */}
+
+
+ {t('common.cancel')}
+
+
+ {t('common.save')}
+
+
+
+
+ );
+};
diff --git a/src/dashboard/src/features/game/components/TimerControlModal.tsx b/src/dashboard/src/features/game/components/TimerControlModal.tsx
new file mode 100644
index 0000000..4476905
--- /dev/null
+++ b/src/dashboard/src/features/game/components/TimerControlModal.tsx
@@ -0,0 +1,99 @@
+import React, {useState} from 'react';
+import {Clock, Play, X} from 'lucide-react';
+import {useTranslation} from '@/lib/i18n';
+
+interface TimerControlModalProps {
+ onClose: () => void;
+ onStart: (seconds: number) => void;
+}
+
+export const TimerControlModal: React.FC = ({onClose, onStart}) => {
+ const {t} = useTranslation();
+ const [minutes, setMinutes] = useState(0);
+ const [seconds, setSeconds] = useState(60);
+
+ const presets = [30, 60, 90, 180];
+
+ const handleStart = () => {
+ const totalSeconds = (minutes * 60) + seconds;
+ if (totalSeconds > 0) {
+ onStart(totalSeconds);
+ onClose();
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t('timer.title') || 'Start Timer'}
+
+
+
+
+
+
+
+ {/* Custom Input */}
+
+
+ {t('timer.minutes')}
+ setMinutes(Math.max(0, parseInt(e.target.value) || 0))}
+ className="w-20 text-center text-2xl font-bold bg-slate-100 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-lg py-2 focus:ring-2 focus:ring-indigo-500 outline-none"
+ />
+
+
:
+
+ {t('timer.seconds')}
+ setSeconds(Math.max(0, parseInt(e.target.value) || 0))}
+ className="w-20 text-center text-2xl font-bold bg-slate-100 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-lg py-2 focus:ring-2 focus:ring-indigo-500 outline-none"
+ />
+
+
+
+ {/* Presets */}
+
+ {presets.map(s => (
+ {
+ setMinutes(Math.floor(s / 60));
+ setSeconds(s % 60);
+ }}
+ className="px-2 py-2 text-sm font-medium bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-md transition-colors"
+ >
+ {s >= 60 ? `${s / 60}m` : `${s}s`}
+
+ ))}
+
+
+
+
+ {t('timer.start') || 'Start Timer'}
+
+
+
+
+ );
+};
diff --git a/src/dashboard/src/features/game/components/VoteStatus.tsx b/src/dashboard/src/features/game/components/VoteStatus.tsx
new file mode 100644
index 0000000..6c0ead6
--- /dev/null
+++ b/src/dashboard/src/features/game/components/VoteStatus.tsx
@@ -0,0 +1,135 @@
+import React, {useEffect, useState} from 'react';
+import {Clock} from 'lucide-react';
+import {Player} from '@/types';
+import {useTranslation} from '@/lib/i18n';
+
+interface VoteStatusProps {
+ candidates: { id: string, voters: string[] }[];
+ totalVoters?: number;
+ endTime?: number;
+ players: Player[];
+ title?: string;
+}
+
+export const VoteStatus: React.FC = ({
+ candidates,
+ totalVoters,
+ endTime,
+ players,
+ title
+ }) => {
+ const {t} = useTranslation();
+ const [timeLeft, setTimeLeft] = useState(0);
+
+ useEffect(() => {
+ if (!endTime) {
+ setTimeLeft(0);
+ return;
+ }
+ const interval = setInterval(() => {
+ const remaining = Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
+ setTimeLeft(remaining);
+ }, 100);
+ return () => clearInterval(interval);
+ }, [endTime]);
+
+ // Calculate total votes cast
+ const totalVotes = candidates.reduce((acc, c) => acc + c.voters.length, 0);
+ const progress = totalVoters ? (totalVotes / totalVoters) * 100 : 0;
+
+ return (
+
+ {/* Timer and Header */}
+
+
{title || t('vote.progress')}
+
+
+
+ {Math.floor(timeLeft / 60).toString().padStart(2, '0')}:{String(timeLeft % 60).padStart(2, '0')}
+
+
+
+
+ {/* Progress Bar */}
+ {totalVoters && (
+
+
+ {t('vote.total')}
+ {totalVotes} / {totalVoters}
+
+
+
+ )}
+
+ {/* Candidates Grid */}
+
+ {candidates.map((candidate) => {
+ const player = players.find(p => p.id === candidate.id);
+ return (
+
+ {/* Candidate Info */}
+
+
+
+
{player?.name || `Candidate ${candidate.id}`}
+
+ {candidate.voters.length} {t('vote.count')}
+
+
+
+
+ {/* Voters List */}
+
+ {candidate.voters.length > 0 ? (
+ candidate.voters.map(voterId => {
+ // Try to find voter by userId (assuming voterId is userId string)
+ // The backend sends userId as string for voters.
+ // Player.userId is key.
+ const voter = players.find(p => p.userId === voterId);
+ return (
+
+
+
+ {voter?.name || 'Unknown'}
+
+
+ );
+ })
+ ) : (
+
{t('vote.noVotes')}
+ )}
+
+
+ );
+ })}
+
+
+ {/* Not Voted List (Optional, maybe for future) */}
+ {totalVoters && (totalVotes < totalVoters) && (
+
+ {t('vote.waiting', {count: String(totalVoters - totalVotes)})}
+
+ )}
+
+ );
+};
diff --git a/src/dashboard/src/features/game/hooks/useGameActions.ts b/src/dashboard/src/features/game/hooks/useGameActions.ts
new file mode 100644
index 0000000..788176c
--- /dev/null
+++ b/src/dashboard/src/features/game/hooks/useGameActions.ts
@@ -0,0 +1,274 @@
+import {useState} from 'react';
+import {useTranslation} from '@/lib/i18n';
+import {api} from '@/lib/api';
+import {GamePhase, GameState} from '@/types';
+import {OverlayState} from './useGameState';
+
+export const useGameActions = (
+ guildId: string | undefined,
+ gameState: GameState,
+ setGameState: React.Dispatch>,
+ setOverlayState: React.Dispatch>
+) => {
+ const {t} = useTranslation();
+
+ // Modal States that are triggered by actions
+ const [showTimerModal, setShowTimerModal] = useState(false);
+ const [editingPlayerId, setEditingPlayerId] = useState(null);
+ const [deathConfirmPlayerId, setDeathConfirmPlayerId] = useState(null);
+ const [playerSelectModal, setPlayerSelectModal] = useState<{
+ visible: boolean;
+ type: 'ASSIGN_JUDGE' | 'DEMOTE_JUDGE' | 'FORCE_POLICE' | null;
+ customPlayers?: any[];
+ }>({visible: false, type: null});
+
+ const addLog = (msg: string) => {
+ setGameState(prev => ({
+ ...prev,
+ logs: [{
+ id: Date.now().toString() + Math.random().toString(36).slice(2),
+ timestamp: new Date().toLocaleTimeString(),
+ message: msg,
+ type: 'info' as const
+ }, ...prev.logs].slice(0, 50)
+ }));
+ };
+
+ const handleAction = async (playerId: string, actionType: string) => {
+ if (!guildId) return;
+ const player = gameState.players.find(p => p.id === playerId);
+ if (!player) return;
+
+ if (actionType === 'role') {
+ setEditingPlayerId(playerId);
+ return;
+ }
+
+ const playerName = player.name;
+ addLog(t('gameLog.adminCommand', {action: actionType, player: playerName}));
+
+ try {
+ if (actionType === 'kill') {
+ if (player.userId) {
+ setDeathConfirmPlayerId(playerId);
+ } else {
+ console.warn('Cannot kill unassigned player via API');
+ }
+ } else if (actionType === 'revive') {
+ if (player.userId) {
+ await api.revivePlayer(guildId, player.userId);
+ }
+ } else if (actionType.startsWith('revive_role:')) {
+ const role = actionType.split(':')[1];
+ if (player.userId) {
+ await api.reviveRole(guildId, player.userId, role);
+ }
+ } else if (actionType === 'toggle-jin') {
+ // Toggle Jin Bao Bao logic
+ } else if (actionType === 'sheriff') {
+ if (player.userId) {
+ await api.setPolice(guildId, player.userId);
+ }
+ } else if (actionType === 'switch_role_order') {
+ if (player.userId) {
+ await api.switchRoleOrder(guildId, player.userId);
+ }
+ }
+
+ } catch (error) {
+ console.error('Action failed:', error);
+ addLog(t('errors.actionFailed', {action: actionType}));
+ }
+ };
+
+ const handleGlobalAction = (action: string) => {
+ addLog(t('gameLog.adminGlobalCommand', {action}));
+ if (action === 'start_game') {
+ setGameState(prev => ({
+ ...prev, phase: 'NIGHT', dayCount: 1, timerSeconds: 30,
+ logs: [...prev.logs, {
+ id: Date.now().toString() + Math.random().toString(36).slice(2),
+ timestamp: new Date().toLocaleTimeString(),
+ message: t('gameLog.gameStarted'),
+ type: 'alert'
+ }]
+ }));
+ // Also call API to start game logic on backend
+ const performStart = async () => {
+ try {
+ if (guildId) {
+ await api.startGame(guildId);
+ }
+ } catch (error: any) {
+ console.error("Start game failed", error);
+ }
+ };
+ performStart();
+
+ } else if (action === 'next_phase') {
+ setGameState(prev => {
+ const phases: GamePhase[] = ['NIGHT', 'DAY', 'VOTING'];
+ const currentIdx = phases.indexOf(prev.phase as any);
+ const nextPhase = currentIdx > -1 ? phases[(currentIdx + 1) % phases.length] : 'NIGHT';
+ return {
+ ...prev,
+ phase: nextPhase,
+ timerSeconds: nextPhase === 'NIGHT' ? 30 : 60,
+ dayCount: nextPhase === 'NIGHT' ? prev.dayCount + 1 : prev.dayCount
+ };
+ });
+ } else if (action === 'pause') {
+ addLog(t('gameLog.gamePaused'));
+ } else if (action === 'reset') {
+ const performReset = async () => {
+ setOverlayState({
+ visible: true,
+ title: t('progressOverlay.resetTitle'),
+ status: 'processing',
+ logs: [t('overlayMessages.resetting')],
+ error: undefined,
+ progress: 0
+ });
+
+ try {
+ if (guildId) {
+ await api.resetSession(guildId);
+ } else {
+ throw new Error("Missing Guild ID");
+ }
+
+ setOverlayState(prev => ({
+ ...prev,
+ status: 'success',
+ logs: [...prev.logs, t('overlayMessages.resetSuccess')]
+ }));
+ } catch (error: any) {
+ console.error("Reset failed", error);
+ setOverlayState(prev => ({
+ ...prev,
+ status: 'error',
+ logs: [...prev.logs, `${t('errors.error')}: ${error.message || t('errors.unknownError')}`],
+ error: error.message || t('errors.resetFailed')
+ }));
+ }
+ };
+ performReset();
+ } else if (action === 'random_assign') {
+ addLog(t('gameLog.randomizeRoles'));
+
+ const performRandomAssign = async () => {
+ setOverlayState({
+ visible: true,
+ title: t('messages.randomAssignRoles'),
+ status: 'processing',
+ logs: [t('overlayMessages.requestingAssign')],
+ error: undefined,
+ progress: 0
+ });
+
+ try {
+ if (guildId) {
+ await api.assignRoles(guildId);
+ } else {
+ throw new Error("Missing Guild ID");
+ }
+
+ setOverlayState(prev => ({
+ ...prev,
+ status: 'success',
+ logs: [...prev.logs, t('overlayMessages.assignSuccess')]
+ }));
+ } catch (error: any) {
+ console.error("Assign failed", error);
+ setOverlayState(prev => ({
+ ...prev,
+ status: 'error',
+ logs: [...prev.logs, `${t('errors.error')}: ${error.message || t('errors.unknownError')}`],
+ error: error.message || t('errors.assignFailed')
+ }));
+ }
+ };
+ performRandomAssign();
+ } else if (action === 'timer_start') {
+ setShowTimerModal(true);
+ } else if (action === 'mute_all') {
+ if (guildId) api.muteAll(guildId).then(() => addLog(t('gameLog.manualCommand', {cmd: 'Mute All'})));
+ } else if (action === 'unmute_all') {
+ if (guildId) api.unmuteAll(guildId).then(() => addLog(t('gameLog.manualCommand', {cmd: 'Unmute All'})));
+ } else if (action === 'assign_judge' || action === 'demote_judge') {
+ if (guildId) {
+ api.getGuildMembers(guildId).then(members => {
+ const mappedPlayers = members.map(m => ({
+ id: m.userId,
+ name: m.name,
+ userId: m.userId,
+ avatar: m.avatar,
+ roles: [],
+ isJudge: m.isJudge,
+ // Defaults
+ deadRoles: [],
+ isAlive: true,
+ isSheriff: false,
+ isJinBaoBao: false,
+ isProtected: false,
+ isPoisoned: false,
+ isSilenced: false,
+ statuses: []
+ }));
+ setPlayerSelectModal({
+ visible: true,
+ type: action === 'assign_judge' ? 'ASSIGN_JUDGE' : 'DEMOTE_JUDGE',
+ customPlayers: mappedPlayers
+ });
+ }).catch(err => {
+ console.error("Failed to fetch members", err);
+ addLog(t('errors.error'));
+ });
+ }
+ } else if (action === 'force_police') {
+ setPlayerSelectModal({visible: true, type: 'FORCE_POLICE'});
+ }
+ };
+
+ const handleTimerStart = (seconds: number) => {
+ if (guildId) {
+ api.manualStartTimer(guildId, seconds);
+ addLog(t('gameLog.manualCommand', {cmd: `Timer ${seconds}s`}));
+ }
+ };
+
+ const handlePlayerSelect = async (playerId: string) => {
+ const player = (playerSelectModal.customPlayers || gameState.players).find(p => p.id === playerId);
+ if (!player || !guildId || !player.userId) return;
+
+ if (playerSelectModal.type === 'ASSIGN_JUDGE') {
+ await api.updateUserRole(guildId, player.userId, 'JUDGE');
+ addLog(t('gameLog.manualCommand', {cmd: `Promote ${player.name} to Judge`}));
+ } else if (playerSelectModal.type === 'DEMOTE_JUDGE') {
+ await api.updateUserRole(guildId, player.userId, 'SPECTATOR');
+ addLog(t('gameLog.manualCommand', {cmd: `Demote ${player.name}`}));
+ } else if (playerSelectModal.type === 'FORCE_POLICE') {
+ await api.setPolice(guildId, player.userId);
+ addLog(t('gameLog.manualCommand', {cmd: `Force Police ${player.name}`}));
+ }
+
+ // Close modal after action?
+ setPlayerSelectModal(prev => ({...prev, visible: false, customPlayers: undefined}));
+ };
+
+ return {
+ handleAction,
+ handleGlobalAction,
+ handleTimerStart,
+ handlePlayerSelect,
+ showTimerModal,
+ setShowTimerModal,
+ editingPlayerId,
+ setEditingPlayerId,
+ deathConfirmPlayerId,
+ setDeathConfirmPlayerId,
+ playerSelectModal,
+ setPlayerSelectModal,
+ addLog
+ };
+};
diff --git a/src/dashboard/src/features/game/hooks/useGameState.ts b/src/dashboard/src/features/game/hooks/useGameState.ts
new file mode 100644
index 0000000..ef66cca
--- /dev/null
+++ b/src/dashboard/src/features/game/hooks/useGameState.ts
@@ -0,0 +1,187 @@
+import {useEffect, useState} from 'react';
+import {useTranslation} from '@/lib/i18n';
+import {useWebSocket} from '@/lib/websocket';
+import {api} from '@/lib/api';
+import {GameState, Player, User} from '@/types';
+import {INITIAL_PLAYERS} from '@/utils/mockData';
+
+export interface OverlayState {
+ visible: boolean;
+ title: string;
+ logs: string[];
+ status: 'processing' | 'success' | 'error';
+ error?: string;
+ progress?: number;
+}
+
+export const useGameState = (guildId: string | undefined, user: User | null) => {
+ const {t} = useTranslation();
+ const [gameState, setGameState] = useState({
+ phase: 'LOBBY',
+ dayCount: 0,
+ timerSeconds: 0,
+ players: INITIAL_PLAYERS,
+ logs: [],
+ });
+
+ const [overlayState, setOverlayState] = useState({
+ visible: false,
+ title: '',
+ logs: [],
+ status: 'processing',
+ error: undefined,
+ progress: undefined
+ });
+
+ const [showSessionExpired, setShowSessionExpired] = useState(false);
+
+ // Helpers to update overlay
+ const setOverlayVisible = (visible: boolean) => setOverlayState(prev => ({...prev, visible}));
+
+ // Helper to map session data to GameState players
+ const mapSessionToPlayers = (sessionData: any): Player[] => {
+ return sessionData.players.map((player: any) => ({
+ id: player.id,
+ name: player.userId ? player.name : `${t('messages.player')} ${player.id} `,
+ userId: player.userId,
+ username: player.username,
+ avatar: player.userId ? player.avatar : null,
+ roles: player.roles || [],
+ deadRoles: player.deadRoles || [],
+ isAlive: player.isAlive,
+ isSheriff: player.police,
+ isJinBaoBao: player.jinBaoBao,
+ isProtected: false,
+ isPoisoned: false,
+ isSilenced: false,
+ isDuplicated: player.duplicated,
+ isJudge: player.isJudge || false,
+ rolePositionLocked: player.rolePositionLocked,
+ statuses: [
+ ...(player.police ? ['sheriff'] : []),
+ ...(player.jinBaoBao ? ['jinBaoBao'] : []),
+ ] as any, // using any to bypass strictly typed array check for now, can be improved
+ }));
+ };
+
+ // WebSocket connection
+ const {isConnected} = useWebSocket((message) => {
+ const {type, data} = message;
+
+ // Check for progress events
+ if (type === 'PROGRESS') {
+ console.log('Incoming PROGRESS event:', {
+ serverGuildId: data.guildId,
+ clientGuildId: guildId,
+ match: data.guildId?.toString() === guildId,
+ message: data.message,
+ percent: data.percent
+ });
+
+ if (data.guildId?.toString() === guildId) {
+ setOverlayState(prev => {
+ const newState = {...prev, visible: true};
+ const isError = data.message && (data.message.includes('錯誤') || data.message.includes('Error') || data.message.includes('Failed'));
+
+ if (data.percent === 0) {
+ newState.logs = data.message ? [data.message] : [];
+ newState.status = 'processing';
+ newState.error = undefined;
+ newState.title = t('progressOverlay.processing');
+ } else if (data.message) {
+ newState.logs = [...prev.logs, data.message];
+ }
+
+ if (isError) {
+ newState.status = 'error';
+ newState.error = data.message || 'Unknown Error';
+ }
+
+ if (data.percent !== undefined) {
+ newState.progress = data.percent;
+ if (data.percent >= 100 && !isError) {
+ newState.status = 'success';
+ }
+ }
+ return newState;
+ });
+ return;
+ }
+ }
+
+ // Check if the update is for the current guild
+ if (type === 'UPDATE' && data && data.guildId && data.guildId.toString() === guildId) {
+ console.log('WebSocket update received:', data);
+
+ const players = mapSessionToPlayers(data);
+ setGameState(prev => ({
+ ...prev,
+ players: players,
+ doubleIdentities: data.doubleIdentities,
+ availableRoles: data.roles || [],
+ speech: data.speech,
+ police: data.police,
+ expel: data.expel, // Ensure expel is updated too
+ logs: data.logs || prev.logs,
+ }));
+ }
+ }, guildId, () => setShowSessionExpired(true));
+
+ // Timer Interval
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setGameState(prev => {
+ let newTimer = prev.timerSeconds;
+ if (prev.phase !== 'LOBBY' && prev.phase !== 'GAME_OVER' && prev.timerSeconds > 0) {
+ newTimer -= 1;
+ }
+ return {...prev, timerSeconds: newTimer};
+ });
+ }, 1000);
+ return () => clearInterval(interval);
+ }, []);
+
+ // Load game state
+ useEffect(() => {
+ if (!guildId) return;
+
+ // Skip fetch if user is not loaded or not on the correct guild yet
+ if (!user || !user.guildId || user.guildId.toString() !== guildId) {
+ return;
+ }
+
+ const loadGameState = async () => {
+ try {
+ const sessionData: any = await api.getSession(guildId);
+ console.log('Session data:', sessionData);
+
+ const players = mapSessionToPlayers(sessionData);
+
+ setGameState(prev => ({
+ ...prev,
+ players: players,
+ doubleIdentities: sessionData.doubleIdentities,
+ availableRoles: sessionData.roles || [],
+ speech: sessionData.speech,
+ police: sessionData.police,
+ expel: sessionData.expel,
+ logs: sessionData.logs || [],
+ }));
+ } catch (error) {
+ console.error('Failed to load session data:', error);
+ }
+ };
+
+ loadGameState();
+ }, [guildId, user, t]);
+
+ return {
+ gameState,
+ setGameState,
+ isConnected,
+ overlayState,
+ setOverlayState,
+ showSessionExpired,
+ setShowSessionExpired
+ };
+};
diff --git a/src/dashboard/src/features/players/components/PlayerCard.tsx b/src/dashboard/src/features/players/components/PlayerCard.tsx
new file mode 100644
index 0000000..0be6323
--- /dev/null
+++ b/src/dashboard/src/features/players/components/PlayerCard.tsx
@@ -0,0 +1,223 @@
+import React, {useEffect, useState} from 'react';
+import {ArrowLeftRight, HeartPulse, Lock, MicOff, Settings, Shield, Skull, Unlock} from 'lucide-react';
+import {Player} from '@/types';
+import {useTranslation} from '@/lib/i18n';
+
+interface PlayerCardProps {
+ player: Player;
+ onAction: (id: string, action: string) => void;
+ readonly?: boolean;
+}
+
+export const PlayerCard: React.FC = ({player, onAction, readonly = false}) => {
+ const {t} = useTranslation();
+ const cardStyle = "bg-slate-100 dark:bg-slate-800/50 rounded-xl border border-slate-300 dark:border-slate-700/50 hover:border-indigo-400 dark:hover:border-indigo-500/50 transition-all duration-200 overflow-hidden relative group";
+
+ const [animate, setAnimate] = useState(false);
+ const [prevRoleString, setPrevRoleString] = useState(JSON.stringify(player.roles));
+ const [swapAnim, setSwapAnim] = useState>({});
+
+ const [showLock, setShowLock] = useState(false);
+ const [fading, setFading] = useState(false);
+ const [prevLocked, setPrevLocked] = useState(player.rolePositionLocked);
+
+ useEffect(() => {
+ const currentRoleString = JSON.stringify(player.roles);
+ if (currentRoleString !== prevRoleString) {
+ let isSwap = false;
+ try {
+ const oldRoles = JSON.parse(prevRoleString);
+ const newRoles = player.roles;
+ if (Array.isArray(oldRoles) && oldRoles.length === 2 && newRoles.length === 2) {
+ if (oldRoles[0] === newRoles[1] && oldRoles[1] === newRoles[0]) {
+ isSwap = true;
+ }
+ }
+ } catch (e) { /* ignore */
+ }
+
+ if (isSwap) {
+ setAnimate(false);
+ setSwapAnim({0: 'animate-slide-left-in', 1: 'animate-slide-right-in'});
+ const t = setTimeout(() => setSwapAnim({}), 400);
+ setPrevRoleString(currentRoleString);
+ return () => clearTimeout(t);
+ } else {
+ setAnimate(true);
+ setPrevRoleString(currentRoleString);
+ const t = setTimeout(() => setAnimate(false), 500);
+ return () => clearTimeout(t);
+ }
+ }
+ }, [player.roles, prevRoleString]);
+
+ useEffect(() => {
+ // Check if transitioning from unlocked to locked
+ if (player.rolePositionLocked === true && prevLocked === false) {
+ setShowLock(true);
+ setFading(false);
+ // Start fade out
+ const t1 = setTimeout(() => setFading(true), 100);
+ const t2 = setTimeout(() => setShowLock(false), 2000);
+ return () => {
+ clearTimeout(t1);
+ clearTimeout(t2);
+ };
+ }
+ setPrevLocked(player.rolePositionLocked);
+ }, [player.rolePositionLocked, prevLocked]);
+
+ // handleKillClick removed, using onAction directly
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ {player.avatar ? (
+
+ ) : (
+
+
+
+ )}
+ {player.isSheriff && (
+
+
+
+ )}
+ {player.isJinBaoBao && (
+
+
+
+ )}
+
+ {/* Unlock Icon - Persistent if unlocked and has multiple roles */}
+ {player.roles.length > 1 && !player.rolePositionLocked && (
+
+
+
+ )}
+
+ {/* Lock Animation Icon - Transient */}
+ {showLock && (
+
+
+
+ )}
+
+
+
+
{player.name}
+ {player.avatar && player.username && (
+
@{player.username}
+ )}
+
+
+
+ {!player.avatar && (
+
+ {t('messages.unassigned')}
+
+ )}
+ {player.roles && player.roles.length > 0 && player.roles.map((role, index) => {
+ // Check if this specific role instance is dead
+ const roleName = role;
+ const previousOccurrences = player.roles.slice(0, index).filter(r => r === roleName).length;
+ const deadOccurrences = player.deadRoles ? player.deadRoles.filter(r => r === roleName).length : 0;
+ const isDeadRole = previousOccurrences < deadOccurrences;
+
+ return (
+ !readonly && isDeadRole ? onAction(player.id, `revive_role:${role}`) : undefined}
+ className={`text-[10px] uppercase tracking-wider font-bold px-1.5 py-0.5 rounded border ${swapAnim[index] || ''}
+ ${isDeadRole ? 'line-through opacity-60 decoration-2 decoration-slate-500' : ''}
+ ${!readonly && isDeadRole ? 'cursor-pointer hover:opacity-100 hover:decoration-red-500 hover:text-red-600 transition-all' : ''}
+ ${role.includes('狼') ? 'bg-red-100 dark:bg-red-900/30 border-red-300 dark:border-red-800 text-red-700 dark:text-red-300' :
+ role.includes('平民') ? 'bg-emerald-100 dark:bg-emerald-900/30 border-emerald-300 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300' :
+ 'bg-indigo-100 dark:bg-indigo-900/30 border-indigo-300 dark:border-indigo-800 text-indigo-700 dark:text-indigo-300'
+ }`}
+ title={!readonly && isDeadRole ? t('players.reviveRole', {role}) : undefined}
+ >
+ {player.roles.length > 1 && `${index + 1}. `}{role}
+
+ );
+ })}
+
+ {!readonly && player.roles.length > 1 && !player.rolePositionLocked && (
+
{
+ e.stopPropagation();
+ onAction(player.id, 'switch_role_order');
+ }}
+ className="p-0.5 rounded hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"
+ title={t('players.switchOrder')}
+ >
+
+
+ )}
+ {!player.isAlive &&
{t('players.dead')} }
+
+
+
+
+ {player.isProtected &&
}
+ {player.isPoisoned &&
}
+ {player.isSilenced &&
}
+
+
+
+
+ {/* Actions (Admin) */}
+ {!readonly && (
+
+ {player.isAlive ? (
+ onAction(player.id, 'kill')}
+ className="text-xs bg-red-100 dark:bg-red-900/40 hover:bg-red-200 dark:hover:bg-red-900/60 text-red-700 dark:text-red-200 border-red-300 dark:border-red-900/50 py-1.5 rounded border flex items-center justify-center gap-1 transition-colors"
+ >
+
+ {t('players.kill')}
+
+ ) : (
+ onAction(player.id, 'revive')}
+ className="text-xs bg-emerald-100 dark:bg-emerald-900/40 hover:bg-emerald-200 dark:hover:bg-emerald-900/60 text-emerald-700 dark:text-emerald-200 py-1.5 rounded border border-emerald-300 dark:border-emerald-900/50 flex items-center justify-center gap-1"
+ >
+ {t('players.revive')}
+
+ )}
+ onAction(player.id, 'role')}
+ className="text-xs bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 py-1.5 rounded border border-slate-400 dark:border-slate-600 flex items-center justify-center gap-1"
+ >
+ {t('players.edit')}
+
+
+ )}
+
+ );
+};
diff --git a/src/dashboard/src/features/players/components/PlayerEditModal.tsx b/src/dashboard/src/features/players/components/PlayerEditModal.tsx
new file mode 100644
index 0000000..4f94e92
--- /dev/null
+++ b/src/dashboard/src/features/players/components/PlayerEditModal.tsx
@@ -0,0 +1,226 @@
+import React, {useState} from 'react';
+import {useTranslation} from '@/lib/i18n';
+import {Player} from '@/types';
+import {ChevronRight, Shield, Users, X} from 'lucide-react';
+import {api} from '@/lib/api';
+
+interface PlayerEditModalProps {
+ player: Player;
+ allPlayers: Player[];
+ guildId: string;
+ onClose: () => void;
+ doubleIdentities?: boolean;
+ availableRoles: string[];
+}
+
+export const PlayerEditModal: React.FC = ({
+ player,
+ allPlayers,
+ guildId,
+ onClose,
+ doubleIdentities,
+ availableRoles
+ }) => {
+ const {t} = useTranslation();
+ const [selectedRole1, setSelectedRole1] = useState(player.roles[0] || '');
+ const [selectedRole2, setSelectedRole2] = useState(player.roles[1] || 'None');
+ const [rolePositionLocked, setRolePositionLocked] = useState(player.rolePositionLocked || false);
+
+ // Default to session setting if available, otherwise fallback to player data
+ const isDoubleIdentity = doubleIdentities !== undefined ? doubleIdentities : player.roles.length > 1;
+
+ const [transferTarget, setTransferTarget] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ // Filter alive players excluding current player for transfer
+ const potentialSheriffs = allPlayers.filter(p => p.isAlive && p.id !== player.id);
+
+ // Use passed availableRoles, filter out duplicates and sort
+ const sortedRoles = Array.from(new Set(availableRoles)).sort();
+ // Ensure "None" isn't in the primary list if possible, or handle it in specific selects
+
+ const handleUpdateRoles = async () => {
+ setLoading(true);
+ try {
+ const newRoles = [selectedRole1];
+ if (isDoubleIdentity && selectedRole2 !== 'None' && selectedRole2 !== '') {
+ newRoles.push(selectedRole2);
+ }
+ // Filter empty
+ const finalRoles = newRoles.filter(r => r);
+
+ // Update roles
+ await api.updatePlayerRoles(guildId, player.id, finalRoles);
+
+ // Update lock status if double identity
+ if (isDoubleIdentity) {
+ await api.setPlayerRoleLock(guildId, player.id, rolePositionLocked);
+ }
+
+ onClose();
+ } catch (error) {
+ console.error('Failed to update roles:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleTransferPolice = async () => {
+ if (!transferTarget) return;
+ setLoading(true);
+ try {
+ await api.setPolice(guildId, transferTarget); // Set new sheriff
+ // We might also need to remove sheriff from current player if API doesn't handle swap automatically
+ // But usually setPolice handles the "who is police" logic.
+ // Assuming setPolice just sets a flag. If we want to strictly transfer,
+ // the backend might need a specific endpoint or we manually unset current.
+ // Based on previous code, the backend `force_police` clears others.
+ // So calling setPolice on target should be enough if using that logic.
+ // Wait, previous code used `api.setPolice` which hits `/police` endpoint.
+
+ // To be safe and since `force_police` logic in Player.java clears others,
+ // we can assume calling it on new target is sufficient.
+
+ onClose();
+ } catch (error) {
+ console.error('Failed to transfer police:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {player.avatar ?
+ : '👤'}
+ {t('players.edit')} - {player.name}
+
+
+
+
+
+
+
+ {/* Role Editing Section */}
+
+
+
+
+ {t('roles.title' as any)}
+
+
+
+
+
+ {t('roles.role' as any)} 1
+ setSelectedRole1(e.target.value)}
+ >
+ {t('roles.unknown')}
+ {sortedRoles.map(role => (
+ {role}
+ ))}
+
+
+
+ {isDoubleIdentity && (
+
+ {t('roles.role' as any)} 2
+ setSelectedRole2(e.target.value)}
+ >
+ {t('common.none', 'None')}
+ {sortedRoles.map(role => (
+ {role}
+ ))}
+
+
+ )}
+
+ {isDoubleIdentity && (
+
+
+
{t('messages.lockRoleOrder')}
+
+ setRolePositionLocked(e.target.checked)}
+ className="sr-only peer"
+ />
+
+
+
+
+ )}
+
+
+ {loading ? '...' : t('common.save')}
+
+
+
+
+ {/* Police Badge Transfer Section */}
+ {player.isSheriff ? (
+
+
+
+ {t('status.sheriff')}
+
+
+
+ {t('players.transferPoliceDescription', 'Transfer the police badge to another alive player.')}
+
+
+
+ setTransferTarget(e.target.value)}
+ >
+ {t('players.selectTarget', 'Select Target...')}
+ {potentialSheriffs.map(p => (
+
+ {p.name}
+
+ ))}
+
+
+ {loading ? '...' : }
+
+
+
+
+ ) : null}
+
+
+
+ );
+};
diff --git a/src/dashboard/src/features/players/components/PlayerSelectModal.tsx b/src/dashboard/src/features/players/components/PlayerSelectModal.tsx
new file mode 100644
index 0000000..37658dc
--- /dev/null
+++ b/src/dashboard/src/features/players/components/PlayerSelectModal.tsx
@@ -0,0 +1,97 @@
+import React, {useState} from 'react';
+import {Check, Search, X} from 'lucide-react';
+import {useTranslation} from '@/lib/i18n';
+import {Player} from '@/types';
+
+interface PlayerSelectModalProps {
+ title: string;
+ players: Player[];
+ onSelect: (playerId: string) => void;
+ onClose: () => void;
+ filter?: (p: Player) => boolean;
+}
+
+export const PlayerSelectModal: React.FC = ({title, players, onSelect, onClose, filter}) => {
+ const {t} = useTranslation();
+ const [search, setSearch] = useState('');
+
+ const filteredPlayers = players
+ .filter(p => filter ? filter(p) : true)
+ .filter(p => p.name.toLowerCase().includes(search.toLowerCase()) || (p.userId && p.userId.includes(search)));
+
+ return (
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="w-full pl-9 pr-4 py-2 bg-slate-100 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ />
+
+
+
+
+ {filteredPlayers.length === 0 ? (
+
+ {t('search.noResults')}
+
+ ) : (
+ filteredPlayers.map(p => (
+
{
+ onSelect(p.id);
+ onClose();
+ }}
+ className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors group text-left"
+ >
+
+
+ {/* Status indicators if needed, e.g. role icons */}
+
+
+
{p.name}
+
+ {p.roles.map(r => {r} )}
+
+
+
+
+ ))
+ )}
+
+
+
+ );
+};
diff --git a/src/dashboard/src/features/server-selection/components/ServerSelector.tsx b/src/dashboard/src/features/server-selection/components/ServerSelector.tsx
new file mode 100644
index 0000000..801647a
--- /dev/null
+++ b/src/dashboard/src/features/server-selection/components/ServerSelector.tsx
@@ -0,0 +1,158 @@
+import {useEffect, useState} from 'react';
+import {Loader2, Server, Users} from 'lucide-react';
+import {useTranslation} from '@/lib/i18n';
+import {api} from '@/lib/api';
+
+interface Session {
+ guildId: string;
+ guildName: string;
+ guildIcon?: string;
+ players?: any[];
+ playerCount?: number;
+}
+
+interface ServerSelectorProps {
+ onSelectServer: (guildId: string) => void;
+ onBack: () => void;
+}
+
+export const ServerSelector: React.FC = ({onSelectServer, onBack}) => {
+ const {t} = useTranslation();
+ const [sessions, setSessions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ loadSessions();
+ }, []);
+
+ const loadSessions = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const response: any = await api.getSessions();
+ console.log('API Response:', response);
+ // Response is the array directly, not wrapped in {data: [...]}
+ const sessionsArray = Array.isArray(response) ? response : (response.data || []);
+ console.log('Sessions array:', sessionsArray);
+ console.log('Sessions length:', sessionsArray.length);
+ setSessions(sessionsArray);
+ } catch (err: any) {
+ console.error('Failed to load sessions:', err);
+ setError(err.message || t('serverSelector.loadError'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ {/* Background Effects */}
+
+
+
+
+
+
+
+
{t('serverSelector.title')}
+
{t('serverSelector.subtitle')}
+
+
+ {loading && (
+
+
+
{t('serverSelector.loading')}
+
+ )}
+
+ {error && (
+
+
{error}
+
+ {t('serverSelector.retry')}
+
+
+ )}
+
+ {!loading && !error && sessions.length === 0 && (
+
+
+
{t('serverSelector.noSessions')}
+
+ {t('serverSelector.noSessionsHint')}
+
+
+ )}
+
+ {!loading && !error && sessions.length > 0 && (
+
+ {sessions.map((session) => (
+
onSelectServer(session.guildId)}
+ className="w-full bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 rounded-xl p-4 transition-all duration-200 flex items-center justify-between group"
+ >
+
+
+ {session.guildIcon ? (
+
+ ) : (
+
+ )}
+
+
+
+ {session.guildName}
+
+
+
+
+ {session.playerCount !== undefined ? session.playerCount : (session.players?.length || 0)} {t('serverSelector.players')}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {t('serverSelector.backToLogin')}
+
+
+
+
+ );
+};
diff --git a/src/dashboard/src/features/server-selection/pages/ServerSelectionPage.tsx b/src/dashboard/src/features/server-selection/pages/ServerSelectionPage.tsx
new file mode 100644
index 0000000..77b38bd
--- /dev/null
+++ b/src/dashboard/src/features/server-selection/pages/ServerSelectionPage.tsx
@@ -0,0 +1,30 @@
+import {useEffect} from 'react';
+import {useNavigate} from 'react-router-dom';
+import {useAuth} from '@/features/auth/contexts/AuthContext';
+import {ServerSelector} from '../components/ServerSelector';
+
+export const ServerSelectionPage = () => {
+ const navigate = useNavigate();
+ const {user, loading} = useAuth();
+
+ useEffect(() => {
+ if (!loading && !user) {
+ navigate('/login');
+ }
+ }, [user, loading, navigate]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!user) return null;
+
+ const handleSelectServer = (guildId: string) => {
+ navigate(`/server/${guildId}`);
+ };
+ return navigate('/login')}/>;
+};
diff --git a/src/dashboard/src/features/spectator/components/SpectatorView.tsx b/src/dashboard/src/features/spectator/components/SpectatorView.tsx
new file mode 100644
index 0000000..d4009d3
--- /dev/null
+++ b/src/dashboard/src/features/spectator/components/SpectatorView.tsx
@@ -0,0 +1,224 @@
+import React, {useMemo} from 'react';
+import {useTranslation} from '@/lib/i18n';
+import {Player} from '@/types';
+import {HeartPulse, Shield, Skull, Users, Zap} from 'lucide-react';
+import {PlayerCard} from '@/features/players/components/PlayerCard';
+
+interface SpectatorViewProps {
+ players: Player[];
+ doubleIdentities: boolean;
+}
+
+export const SpectatorView: React.FC = ({players, doubleIdentities}) => {
+ const {t} = useTranslation();
+
+ const stats = useMemo(() => {
+ let wolves = 0;
+ let deadWolves = 0;
+ let gods = 0;
+ let deadGods = 0;
+ let villagers = 0;
+ let deadVillagers = 0;
+ let jinBaoBaos = 0;
+ let deadJinBaoBaos = 0;
+
+ // Detailed Iteration
+ players.forEach(player => {
+ // JBB Logic
+ if (player.isJinBaoBao) {
+ jinBaoBaos++;
+ if (!player.isAlive) {
+ deadJinBaoBaos++;
+ }
+ }
+
+ // Roles Logic
+ const pRoles = player.roles || [];
+ const pDeadRoles = player.deadRoles || [];
+
+ // We need to match dead roles to actual roles to calculate stats.
+ // We can just count totals.
+
+ let localDead = [...pDeadRoles];
+
+ pRoles.forEach(role => {
+ const isWolf = role.includes('狼') || role === '石像鬼' || role === '血月使者' || role === '惡靈騎士' || role === '夢魘';
+ const isVillager = role === '平民';
+ const isGod = !isWolf && !isVillager;
+
+ // Check if this role is dead
+ let isDead = false;
+ const deadIdx = localDead.indexOf(role);
+ if (deadIdx !== -1) {
+ isDead = true;
+ localDead.splice(deadIdx, 1); // Remove matched dead role
+ }
+
+ if (isWolf) {
+ wolves++;
+ if (isDead) deadWolves++;
+ } else if (isVillager) {
+ villagers++;
+ if (isDead) deadVillagers++;
+ } else if (isGod) {
+ gods++;
+ if (isDead) deadGods++;
+ }
+ });
+ });
+
+ return {
+ wolves, deadWolves,
+ gods, deadGods,
+ villagers, deadVillagers,
+ jinBaoBaos, deadJinBaoBaos
+ };
+ }, [players]);
+
+ return (
+
+
+
{t('spectator.title')}
+
{t('spectator.subtitle')}
+
+
+
+ {/* Wolves Status */}
+ }
+ color="red"
+ current={stats.wolves - stats.deadWolves}
+ total={stats.wolves}
+ description={t('spectator.wolvesLeftDesc')}
+ />
+
+ {/* Good Faction Status */}
+ {doubleIdentities ? (
+ <>
+ }
+ color="yellow"
+ current={stats.gods - stats.deadGods}
+ total={stats.gods}
+ description={t('spectator.godsLeftDesc')}
+ />
+ }
+ color="pink"
+ current={stats.jinBaoBaos - stats.deadJinBaoBaos}
+ total={stats.jinBaoBaos}
+ description={t('spectator.jbbLeftDesc')}
+ />
+ >
+ ) : (
+ <>
+ }
+ color="yellow"
+ current={stats.gods - stats.deadGods}
+ total={stats.gods}
+ description={t('spectator.godsLeftDesc')}
+ />
+ }
+ color="emerald"
+ current={stats.villagers - stats.deadVillagers}
+ total={stats.villagers}
+ description={t('spectator.villagersLeftDesc')}
+ />
+ >
+ )}
+
+
+
+
{t('spectator.winConditions')}
+
+ {t('spectator.goodWinCondition')}
+ {doubleIdentities ? t('spectator.wolfWinConditionDouble') : t('spectator.wolfWinConditionNormal')}
+
+
+
+ {/* Read-only Player Grid */}
+
+
+
+ {t('players.title')} ({players.filter(p => p.isAlive).length} {t('players.alive')})
+
+
+ {players.map(player => (
+
{
+ }}
+ readonly={true}
+ />
+ ))}
+
+
+
+
+
+ );
+};
+
+interface FactionCardProps {
+ title: string;
+ icon: React.ReactNode;
+ color: 'red' | 'yellow' | 'emerald' | 'pink';
+ current: number;
+ total: number;
+ description: string;
+}
+
+const FactionCard: React.FC = ({title, icon, color, current, total, description}) => {
+ const {t} = useTranslation();
+ const percentage = total > 0 ? ((total - current) / total) * 100 : 0;
+
+ const colorClasses = {
+ red: {bg: 'bg-red-500', bar: 'bg-red-500', text: 'text-red-600 dark:text-red-400'},
+ yellow: {bg: 'bg-yellow-500', bar: 'bg-yellow-500', text: 'text-yellow-600 dark:text-yellow-400'},
+ emerald: {bg: 'bg-emerald-500', bar: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400'},
+ pink: {bg: 'bg-pink-500', bar: 'bg-pink-500', text: 'text-pink-600 dark:text-pink-400'},
+ };
+
+ return (
+
+
+
+
+ {icon}
+
+
+
{title}
+
{description}
+
+
+
+ {current}/{total}
+
+
+
+
+
+ {t('messages.progress')}
+ {Math.round(percentage)}%
+
+
+
+
+ );
+};
diff --git a/src/dashboard/src/features/speech/components/SpeakerCard.tsx b/src/dashboard/src/features/speech/components/SpeakerCard.tsx
new file mode 100644
index 0000000..e9b8b97
--- /dev/null
+++ b/src/dashboard/src/features/speech/components/SpeakerCard.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import {Clock, Mic, SkipForward, Square} from 'lucide-react';
+import {Player} from '../types';
+
+interface SpeakerCardProps {
+ player: Player;
+ timeLeft: number;
+ t: any;
+ readonly: boolean;
+ onSkip?: () => void;
+ onInterrupt?: () => void;
+}
+
+export const SpeakerCard = ({player, timeLeft, t, readonly, onSkip, onInterrupt}: SpeakerCardProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
{player.name}
+ {t('speechManager.speaking')}
+
+
+
+
+
+ {Math.floor(timeLeft / 60).toString().padStart(2, '0')}:{String(timeLeft % 60).padStart(2, '0')}
+
+
+
+ {!readonly && (
+
+
+
+ {t('speechManager.skip')}
+
+
+
+ {t('speechManager.interrupt')}
+
+
+ )}
+
+
+);
diff --git a/src/dashboard/src/features/speech/components/SpeechManager.tsx b/src/dashboard/src/features/speech/components/SpeechManager.tsx
new file mode 100644
index 0000000..cb2f07f
--- /dev/null
+++ b/src/dashboard/src/features/speech/components/SpeechManager.tsx
@@ -0,0 +1,389 @@
+import {useEffect, useState} from 'react';
+import {ArrowDown, ArrowUp, Clock, Mic, Play, Shield, Square, UserMinus, UserPlus} from 'lucide-react';
+import {Player, PoliceState, SpeechState} from '@/types';
+import {VoteStatus} from '@/features/game/components/VoteStatus';
+import {api} from '@/lib/api';
+import {useTranslation} from '@/lib/i18n';
+
+import {SpeakerCard} from './SpeakerCard';
+
+interface SpeechManagerProps {
+ speech?: SpeechState;
+ police?: PoliceState;
+ players: Player[];
+ guildId: string;
+ readonly?: boolean;
+}
+
+export const SpeechManager = ({speech, police, players, guildId, readonly = false}: SpeechManagerProps) => {
+ const {t} = useTranslation();
+ const [timeLeft, setTimeLeft] = useState(0);
+
+ useEffect(() => {
+ if (!speech || !speech.endTime) {
+ setTimeLeft(0);
+ return;
+ }
+ const interval = setInterval(() => {
+ const remaining = Math.max(0, Math.ceil((speech.endTime - Date.now()) / 1000));
+ setTimeLeft(remaining);
+ }, 100);
+ return () => clearInterval(interval);
+ }, [speech?.endTime]);
+
+ const handleStart = async () => {
+ await api.startSpeech(guildId);
+ };
+
+ const handlePoliceEnroll = async () => {
+ await api.startPoliceEnroll(guildId);
+ };
+
+ const handleSkip = async () => {
+ await api.skipSpeech(guildId);
+ };
+
+ const handleInterrupt = async () => {
+ await api.interruptSpeech(guildId);
+ };
+
+ const handleSetOrder = async (direction: 'UP' | 'DOWN') => {
+ await api.setSpeechOrder(guildId, direction);
+ };
+
+ const isPoliceSelecting = speech && !speech.currentSpeakerId && (!speech.order || speech.order.length === 0);
+ const isSpeechActive = speech && (speech.currentSpeakerId || (speech.order && speech.order.length > 0) || isPoliceSelecting);
+
+ // Check if police enrollment is ACTIVELY happening (not just if candidates exist)
+ // Only show police UI when enrollment or unenrollment is allowed
+ // Check if police enrollment is ACTIVELY happening (not just if candidates exist)
+ // Only show police UI when enrollment or unenrollment is allowed OR voting
+ const isPoliceActive = police && (police.allowEnroll || police.allowUnEnroll || police.state === 'VOTING');
+
+ const isActive = isSpeechActive || isPoliceActive;
+
+ const currentSpeaker = speech?.currentSpeakerId ? players.find(p => p.id === speech.currentSpeakerId) : null;
+ const nextPlayers = speech?.order ? speech.order.map(id => players.find(p => p.id === id)).filter(Boolean) as Player[] : [];
+
+ // Animation State
+ const [renderedSpeaker, setRenderedSpeaker] = useState(null);
+ const [exitingSpeaker, setExitingSpeaker] = useState(null);
+ const [speakerAnimation, setSpeakerAnimation] = useState('animate-in fade-in zoom-in-95');
+
+ // Sync renderedSpeaker with currentSpeaker on mount and updates
+ useEffect(() => {
+ // If we have a current speaker but nothing is rendered, initialize it immediately
+ if (currentSpeaker && !renderedSpeaker && !exitingSpeaker) {
+ setRenderedSpeaker(currentSpeaker);
+ setSpeakerAnimation('animate-in fade-in zoom-in-95');
+ }
+ }, [currentSpeaker, renderedSpeaker, exitingSpeaker]);
+
+ useEffect(() => {
+ // When current speaker changes...
+ if (currentSpeaker?.id !== renderedSpeaker?.id) {
+ // If we have a currently rendered speaker, animate them out
+ if (renderedSpeaker) {
+ setExitingSpeaker(renderedSpeaker);
+ setRenderedSpeaker(currentSpeaker);
+ setSpeakerAnimation('animate-swipe-in');
+
+ const timer = setTimeout(() => {
+ setExitingSpeaker(null);
+ // Reset animation to a stable state after transition
+ setSpeakerAnimation('animate-in fade-in zoom-in-95');
+ }, 400); // Match animation duration
+ return () => clearTimeout(timer);
+ } else {
+ // Otherwise just show the new one immediately
+ setRenderedSpeaker(currentSpeaker);
+ setSpeakerAnimation('animate-in fade-in zoom-in-95');
+ }
+ }
+ }, [currentSpeaker, renderedSpeaker]);
+
+ if (!isActive) {
+ return (
+
+
+
+
+
{t('sidebar.speechManager')}
+
+ {readonly ? t('speechManager.noActiveSpeech') : t('speechManager.noActiveSpeechJudge')}
+
+ {!readonly && (
+
+
+
+ {t('speechManager.startAuto')}
+
+
+
+ {t('speechManager.startPoliceEnroll')}
+
+
+ )}
+
+ );
+ }
+
+ // Police Voting Phase
+ if (police?.state === 'VOTING') {
+ const stageEndTime = police.stageEndTime;
+
+ return (
+
+
+ !c.quit)}
+ totalVoters={players.filter(p => p.isAlive && !police.candidates.some(c => c.id === String(p.id))).length}
+ endTime={stageEndTime}
+ players={players}
+ title={t('vote.policeElection')}
+ />
+
+
+ );
+ }
+
+ // If Police Enrollment is active and no speech is active (or even if it is, show police view if appropriate? usually exclusive)
+ // Assuming police enrollment happens before speech starts.
+ if (isPoliceActive && !isSpeechActive) {
+ // Unenrollment Timer Logic
+ const isUnenrollment = police?.state === 'UNENROLLMENT';
+ const timerSeconds = police?.stageEndTime ? Math.max(0, Math.ceil((police.stageEndTime - Date.now()) / 1000)) : 0;
+
+ // Force re-render for timer if needed, but App likely triggers updates.
+ // We can reuse local state or just rely on props updates.
+ // For smoother countdown we might need a useEffect driven timer like for speech.
+
+ return (
+
+
+
+
+ {isUnenrollment ? t('speechManager.policeUnenrollment') : t('speechManager.policeEnrollment')}
+
+ {timerSeconds > 0 && (
+
+
+ {Math.floor(timerSeconds / 60).toString().padStart(2, '0')}:{String(timerSeconds % 60).padStart(2, '0')}
+
+ )}
+
+
+
+
+
+ {police?.allowEnroll ? : }
+ {t('speechManager.allowEnroll')}: {police?.allowEnroll ? 'YES' : 'NO'}
+
+
+
+
+ {police?.allowUnEnroll ? : }
+ {t('speechManager.allowUnEnroll')}: {police?.allowUnEnroll ? 'YES' : 'NO'}
+
+
+
+
+
+
{t('speechManager.candidates')}
+ {police?.candidates && police.candidates.length > 0 ? (
+
+ {police.candidates.map(candidate => {
+ const p = players.find(x => x.id === candidate.id);
+ return (
+
+
+
{p?.name || `Player ${candidate.id}`}
+
+ );
+ })}
+
+ ) : (
+
{t('speechManager.noCandidates')}
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
{t('speechManager.activeSpeech')}
+
{t('speechManager.autoProcess')}
+
+
+
+
+
+
+ {isPoliceSelecting ? (
+
+
+
+
+
+
+
{t('speechManager.waitingForPolice')}
+
{t('speechManager.waitingForPoliceSub')}
+
+
+
+
+ {!readonly && (
+
+
{t('speechManager.judgeOverride')}
+
handleSetOrder('UP')}
+ className="flex items-center justify-center gap-2 px-6 py-3 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-lg transition-colors border border-slate-200 dark:border-slate-600"
+ >
+
+ {t('speechManager.forceUp')}
+
+
handleSetOrder('DOWN')}
+ className="flex items-center justify-center gap-2 px-6 py-3 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-lg transition-colors border border-slate-200 dark:border-slate-600"
+ >
+
+ {t('speechManager.forceDown')}
+
+
+ )}
+
+ ) : (
+ <>
+ {/* Current Speaker Node */}
+ {/* Speaker Area with Swiping Animation */}
+
+ {exitingSpeaker && (
+
+
+
+ )}
+
+ {renderedSpeaker ? (
+
+
+
+ ) : (
+ !exitingSpeaker &&
+
{t('speechManager.preparing')}
+ )}
+
+
+ {/* Interrupt Votes */}
+ {speech?.interruptVotes && speech.interruptVotes.length > 0 && (
+
+
+
+ {t('speechManager.interruptVote')} ({speech.interruptVotes.length} / {Math.floor(players.filter(p => p.isAlive).length / 2) + 1})
+
+
+ {speech.interruptVotes.map(voterId => {
+ const voter = players.find(p => String(p.userId) === String(voterId));
+ return (
+
+ {voter?.avatar &&
+
}
+
{voter?.name || voterId}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Queue */}
+
+ {nextPlayers && nextPlayers.map((player, idx) => (
+
+ {idx > 0 && (
+
+ )}
+
+
+
+ {idx + 1}
+
+
+
+
{player.name}
+
+
+
{t('speechManager.waiting')}
+
+
+ ))}
+
+
+ {nextPlayers && nextPlayers.length === 0 && currentSpeaker && (
+
{t('speechManager.noMoreSpeakers')}
+ )}
+ >
+ )}
+
+
+ );
+};
+
+
diff --git a/src/dashboard/src/index.css b/src/dashboard/src/index.css
new file mode 100644
index 0000000..5b7b9ea
--- /dev/null
+++ b/src/dashboard/src/index.css
@@ -0,0 +1,176 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ * {
+ transition: background-color 0.2s ease, border-color 0.2s ease;
+ }
+
+ body {
+ @apply bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100;
+ }
+}
+
+/* Custom Scrollbar */
+.scrollbar-hide::-webkit-scrollbar {
+ display: none;
+}
+
+.scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+@keyframes flash-highlight {
+ 0% {
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.05);
+ box-shadow: 0 0 15px rgba(99, 102, 241, 0.5);
+ border-color: #6366f1;
+ }
+
+ 100% {
+ transform: scale(1);
+ }
+}
+
+.animate-flash {
+ animation: flash-highlight 0.5s ease-in-out;
+}
+
+@keyframes slide-right-in {
+ from {
+ transform: translateX(-100%);
+ opacity: 0.5;
+ }
+
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slide-left-in {
+ from {
+ transform: translateX(100%);
+ opacity: 0.5;
+ }
+
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+.animate-slide-right-in {
+ animation: slide-right-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
+}
+
+.animate-slide-left-in {
+ animation: slide-left-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
+}
+
+/* Animations Logic for Tailwind-Animate compatibility */
+.animate-in {
+ animation-duration: 0.5s;
+ animation-fill-mode: forwards;
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ --tw-enter-opacity: initial;
+ --tw-enter-scale: initial;
+ --tw-enter-translate-x: initial;
+ --tw-enter-translate-y: initial;
+}
+
+.fade-in {
+ --tw-enter-opacity: 0;
+ animation-name: enter;
+}
+
+.zoom-in-95 {
+ --tw-enter-scale: 0.95;
+}
+
+.zoom-in-75 {
+ --tw-enter-scale: 0.75;
+}
+
+.slide-in-from-bottom-2 {
+ --tw-enter-translate-y: 0.5rem;
+}
+
+.slide-in-from-bottom-4 {
+ --tw-enter-translate-y: 1rem;
+}
+
+.slide-in-from-right {
+ --tw-enter-translate-x: 100%;
+}
+
+@keyframes enter {
+ from {
+ opacity: var(--tw-enter-opacity, 1);
+ transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), 1);
+ }
+}
+
+/* Custom Swiping Animations */
+@keyframes swipe-out-left {
+ to {
+ transform: translateX(-120%) scale(0.9);
+ opacity: 0;
+ }
+}
+
+@keyframes swipe-in-right {
+ from {
+ transform: translateX(120%) scale(0.9);
+ opacity: 0;
+ }
+
+ to {
+ transform: translateX(0) scale(1);
+ opacity: 1;
+ }
+}
+
+.animate-swipe-out {
+ animation: swipe-out-left 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
+}
+
+.animate-swipe-in {
+ animation: swipe-in-right 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
+}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes scale-up {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+.animate-fade-in {
+ animation: fade-in 0.2s ease-out forwards;
+}
+
+.animate-scale-up {
+ animation: scale-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
+}
\ No newline at end of file
diff --git a/src/dashboard/src/lib/ThemeProvider.tsx b/src/dashboard/src/lib/ThemeProvider.tsx
new file mode 100644
index 0000000..834ec33
--- /dev/null
+++ b/src/dashboard/src/lib/ThemeProvider.tsx
@@ -0,0 +1,61 @@
+import React, {createContext, ReactNode, useContext, useEffect, useState} from 'react';
+
+type Theme = 'light' | 'dark';
+
+interface ThemeContextType {
+ theme: Theme;
+ toggleTheme: () => void;
+}
+
+const ThemeContext = createContext(undefined);
+
+export const useTheme = () => {
+ const context = useContext(ThemeContext);
+ if (!context) {
+ throw new Error('useTheme must be used within ThemeProvider');
+ }
+ return context;
+};
+
+interface ThemeProviderProps {
+ children: ReactNode;
+}
+
+export const ThemeProvider: React.FC = ({children}) => {
+ const [theme, setTheme] = useState(() => {
+ // Check localStorage first
+ const stored = localStorage.getItem('theme') as Theme | null;
+ if (stored === 'light' || stored === 'dark') {
+ return stored;
+ }
+
+ // Fall back to system preference
+ if (window.matchMedia('(prefers-color-scheme: light)').matches) {
+ return 'light';
+ }
+
+ return 'dark';
+ });
+
+ useEffect(() => {
+ const root = document.documentElement;
+
+ if (theme === 'dark') {
+ root.classList.add('dark');
+ } else {
+ root.classList.remove('dark');
+ }
+
+ localStorage.setItem('theme', theme);
+ }, [theme]);
+
+ const toggleTheme = () => {
+ setTheme(prev => prev === 'light' ? 'dark' : 'light');
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/dashboard/src/lib/api.ts b/src/dashboard/src/lib/api.ts
new file mode 100644
index 0000000..8808ce9
--- /dev/null
+++ b/src/dashboard/src/lib/api.ts
@@ -0,0 +1,223 @@
+export class ApiClient {
+ private baseUrl: string;
+
+ constructor() {
+ this.baseUrl = this.getBackendUrl();
+ console.log('ApiClient initialized with baseUrl:', this.baseUrl);
+ if (this.baseUrl !== '') {
+ console.warn('Backend URL is not empty! Forcing reset.');
+ this.baseUrl = '';
+ }
+ }
+
+ private getBackendUrl(): string {
+ // return localStorage.getItem('backendUrl') || DEFAULT_BACKEND_URL;
+ return ''; // Force local proxy
+ }
+
+ public setBackendUrl(url: string) {
+ localStorage.setItem('backendUrl', url);
+ this.baseUrl = url;
+ }
+
+ public getConfiguredUrl(): string {
+ return this.baseUrl;
+ }
+
+ private async request(endpoint: string, options?: RequestInit): Promise {
+ const url = `${this.baseUrl}${endpoint}`;
+
+ try {
+ const response = await fetch(url, {
+ credentials: 'include', // Ensure cookies are sent
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ },
+ });
+
+ let data;
+ try {
+ data = await response.json();
+ } catch (e) {
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ throw new Error('Failed to parse response');
+ }
+
+ if (!response.ok) {
+ throw new Error(data.error || data.message || `HTTP error! status: ${response.status}`);
+ }
+
+ if (data.success === false) {
+ throw new Error(data.error || 'API request failed');
+ }
+
+ return data.data || data;
+ } catch (error) {
+ console.error(`API request failed: ${endpoint}`, error);
+ throw error;
+ }
+ }
+
+ // Session endpoints
+ async getSessions() {
+ return this.request('/api/sessions');
+ }
+
+ async getSession(guildId: string) {
+ return this.request(`/api/sessions/${guildId}`);
+ }
+
+ // Player endpoints
+ async getPlayers(guildId: string) {
+ return this.request(`/api/sessions/${guildId}/players`);
+ }
+
+ async assignRoles(guildId: string) {
+ return this.request(`/api/sessions/${guildId}/players/assign`, {method: 'POST'});
+ }
+
+ async markPlayerDead(guildId: string, userId: string, lastWords: boolean = false) {
+ return this.request(`/api/sessions/${guildId}/players/${userId}/died?lastWords=${lastWords}`, {method: 'POST'});
+ }
+
+ async setPolice(guildId: string, userId: string) {
+ return this.request(`/api/sessions/${guildId}/players/${userId}/police`, {method: 'POST'});
+ }
+
+ async updatePlayerRoles(guildId: string, userId: string, roles: string[]) {
+ return this.request(`/api/sessions/${guildId}/players/${userId}/roles`, {
+ method: 'POST',
+ body: JSON.stringify(roles)
+ });
+ }
+
+ async reviveRole(guildId: string, userId: string, role: string) {
+ return this.request(`/api/sessions/${guildId}/players/${userId}/revive-role?role=${encodeURIComponent(role)}`, {method: 'POST'});
+ }
+
+ async revivePlayer(guildId: string, userId: string) {
+ return this.request(`/api/sessions/${guildId}/players/${userId}/revive`, {method: 'POST'});
+ }
+
+ // Role management
+ async getRoles(guildId: string) {
+ return this.request(`/api/sessions/${guildId}/roles`);
+ }
+
+ async addRole(guildId: string, role: string, amount: number = 1) {
+ return this.request(`/api/sessions/${guildId}/roles/add?role=${encodeURIComponent(role)}&amount=${amount}`, {method: 'POST'});
+ }
+
+ async removeRole(guildId: string, role: string, amount: number = 1) {
+ return this.request(`/api/sessions/${guildId}/roles/${encodeURIComponent(role)}?amount=${amount}`, {method: 'DELETE'});
+ }
+
+ // Role Order
+ async switchRoleOrder(guildId: string, playerId: string) {
+ return this.request(`/api/sessions/${guildId}/players/${playerId}/switch-role-order`, {
+ method: 'POST'
+ });
+ }
+
+ // Role Position Lock
+ async setPlayerRoleLock(guildId: string, playerId: string, locked: boolean) {
+ return this.request(`/api/sessions/${guildId}/players/${playerId}/role-lock?locked=${locked}`, {
+ method: 'POST'
+ });
+ }
+
+ // Settings
+ async updateSettings(guildId: string, settings: { doubleIdentities?: boolean; muteAfterSpeech?: boolean }) {
+ return this.request(`/api/sessions/${guildId}/settings`, {
+ method: 'PUT',
+ body: JSON.stringify(settings)
+ });
+ }
+
+ async setPlayerCount(guildId: string, count: number) {
+ return this.request(`/api/sessions/${guildId}/player-count`, {
+ method: 'POST',
+ body: JSON.stringify({count})
+ });
+ }
+
+ // Speech endpoints
+ async startSpeech(guildId: string) {
+ return this.request(`/api/sessions/${guildId}/speech/auto`, {method: 'POST'});
+ }
+
+ async skipSpeech(guildId: string) {
+ return this.request(`/api/sessions/${guildId}/speech/skip`, {method: 'POST'});
+ }
+
+ async interruptSpeech(guildId: string) {
+ return this.request(`/api/sessions/${guildId}/speech/interrupt`, {method: 'POST'});
+ }
+
+ async startPoliceEnroll(guildId: string) {
+ return this.request(`/api/sessions/${guildId}/speech/police-enroll`, {method: 'POST'});
+ }
+
+ async setSpeechOrder(guildId: string, direction: 'UP' | 'DOWN') {
+ return this.request(`/api/sessions/${guildId}/speech/order`, {
+ method: 'POST',
+ body: JSON.stringify({direction})
+ });
+ }
+
+ async confirmSpeech(guildId: string) {
+ return this.request(`/api/sessions/${guildId}/speech/confirm`, {method: 'POST'});
+ }
+
+ // Start and Reset session
+ async startGame(guildId: string) {
+ return this.request(`/api/sessions/${guildId}/start`, {method: 'POST'});
+ }
+
+ async resetSession(guildId: string) {
+ return this.request(`/api/sessions/${guildId}/reset`, {method: 'POST'});
+ }
+
+ // New Commands (Timer, Voice, Roles)
+ async manualStartTimer(guildId: string, duration: number) {
+ return this.request(`/api/sessions/${guildId}/speech/manual-start`, {
+ method: 'POST',
+ body: JSON.stringify({duration})
+ });
+ }
+
+ async muteAll(guildId: string) {
+ return this.request(`/api/sessions/${guildId}/speech/mute-all`, {method: 'POST'});
+ }
+
+ async unmuteAll(guildId: string) {
+ return this.request(`/api/sessions/${guildId}/speech/unmute-all`, {method: 'POST'});
+ }
+
+ async updateUserRole(guildId: string, userId: string, role: string) {
+ return this.request(`/api/sessions/${guildId}/players/${userId}/role`, {
+ method: 'POST',
+ body: JSON.stringify({role})
+ });
+ }
+
+ async getGuildMembers(guildId: string): Promise {
+ return this.request(`/api/sessions/${guildId}/members`);
+ }
+
+ // Test connection
+ async testConnection(): Promise {
+ try {
+ await this.getSessions();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+}
+
+export const api = new ApiClient();
diff --git a/src/dashboard/src/lib/i18n.ts b/src/dashboard/src/lib/i18n.ts
new file mode 100644
index 0000000..32e9977
--- /dev/null
+++ b/src/dashboard/src/lib/i18n.ts
@@ -0,0 +1,38 @@
+import zhTW from '../locales/zh-TW.json';
+
+type TranslationKey = string;
+type Translations = typeof zhTW;
+
+// Simple i18n without external dependencies
+const translations: Translations = zhTW;
+
+export const useTranslation = () => {
+ const t = (key: TranslationKey, params?: Record): string => {
+ const keys = key.split('.');
+ let value: any = translations;
+
+ for (const k of keys) {
+ if (value && typeof value === 'object' && k in value) {
+ value = value[k];
+ } else {
+ return key; // Return key if translation not found
+ }
+ }
+
+ if (typeof value !== 'string') {
+ return key;
+ }
+
+ // Replace parameters in the translation string
+ if (params) {
+ return Object.entries(params).reduce(
+ (str, [key, val]) => str.replace(`{${key}}`, val),
+ value
+ );
+ }
+
+ return value;
+ };
+
+ return {t};
+};
diff --git a/src/dashboard/src/lib/websocket.ts b/src/dashboard/src/lib/websocket.ts
new file mode 100644
index 0000000..559f38a
--- /dev/null
+++ b/src/dashboard/src/lib/websocket.ts
@@ -0,0 +1,201 @@
+import {useEffect, useRef, useState} from 'react';
+import {api} from './api';
+
+type MessageHandler = (data: any) => void;
+
+export class WebSocketClient {
+ private ws: WebSocket | null = null;
+ private reconnectTimeout: number | null = null;
+ private messageHandlers: Set = new Set();
+ private url: string;
+ private reconnectAttempts = 0;
+ private guildId: string | null = null;
+
+ private onConnectHandlers: Set<() => void> = new Set();
+ private onDisconnectHandlers: Set<(event: CloseEvent) => void> = new Set();
+
+ constructor() {
+ this.url = this.getWebSocketUrl();
+ }
+
+ private getWebSocketUrl(): string {
+ const backendUrl = api.getConfiguredUrl();
+ const query = this.guildId ? `?guildId=${this.guildId}` : '';
+ if (!backendUrl) {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const host = window.location.host;
+ return `${protocol}//${host}/ws${query}`;
+ }
+ return (backendUrl.replace(/^http/, 'ws') + '/ws').replace(/\/+$/, '') + query;
+ }
+
+ public get isConnected(): boolean {
+ return this.ws?.readyState === WebSocket.OPEN;
+ }
+
+ connect(guildId?: string) {
+ if (guildId !== undefined) {
+ if (this.guildId !== guildId) {
+ this.guildId = guildId;
+ if (this.ws) {
+ this.disconnect();
+ }
+ }
+ }
+
+ if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
+ return;
+ }
+
+ this.url = this.getWebSocketUrl();
+ console.log(`Connecting to WebSocket at ${this.url}`);
+
+ try {
+ this.ws = new WebSocket(this.url);
+
+ this.ws.onopen = () => {
+ console.log('WebSocket connected');
+ this.reconnectAttempts = 0;
+ this.onConnectHandlers.forEach(h => h());
+ if (this.reconnectTimeout) {
+ clearTimeout(this.reconnectTimeout);
+ this.reconnectTimeout = null;
+ }
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ if (data.type === 'PONG') return;
+ this.messageHandlers.forEach(handler => handler(data));
+ } catch (error) {
+ // Ignore parsing errors for heartbeats
+ }
+ };
+
+ this.ws.onerror = (error) => {
+ console.error('WebSocket error:', error);
+ // onerror is usually followed by onclose
+ };
+
+ this.ws.onclose = (event) => {
+ console.log(`WebSocket closed (code: ${event.code}, reason: ${event.reason}), reconnecting...`);
+ this.ws = null;
+ this.onDisconnectHandlers.forEach(h => h(event));
+ this.reconnect();
+ };
+ } catch (error) {
+ console.error('Failed to create WebSocket:', error);
+ this.reconnect();
+ }
+ }
+
+ private reconnect() {
+ if (this.reconnectTimeout) return;
+
+ // Exponential backoff: 1s, 2s, 5s, max 10s
+ const delay = Math.min(1000 * Math.pow(1.5, this.reconnectAttempts), 10000);
+ this.reconnectAttempts++;
+
+ this.reconnectTimeout = window.setTimeout(() => {
+ this.reconnectTimeout = null;
+ console.log(`Attempting to reconnect WebSocket (attempt ${this.reconnectAttempts})...`);
+ this.connect();
+ }, delay);
+ }
+
+ disconnect() {
+ if (this.reconnectTimeout) {
+ clearTimeout(this.reconnectTimeout);
+ this.reconnectTimeout = null;
+ }
+
+ if (this.ws) {
+ this.ws.onclose = null; // Prevent auto-reconnect on manual disconnect
+ const closeEvent = new CloseEvent('close', {code: 1000, reason: 'Manual disconnect'});
+ this.ws.close();
+ this.ws = null;
+ this.onDisconnectHandlers.forEach(h => h(closeEvent));
+ }
+ }
+
+ addConnectionHandlers(onConnect: () => void, onDisconnect: (event: CloseEvent) => void) {
+ this.onConnectHandlers.add(onConnect);
+ this.onDisconnectHandlers.add(onDisconnect);
+
+ // Return cleanup function
+ return () => {
+ this.onConnectHandlers.delete(onConnect);
+ this.onDisconnectHandlers.delete(onDisconnect);
+ };
+ }
+
+ onMessage(handler: MessageHandler) {
+ this.messageHandlers.add(handler);
+ return () => this.messageHandlers.delete(handler);
+ }
+
+ send(data: any) {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(data));
+ } else {
+ console.warn('Cannot send message: WebSocket is not open');
+ }
+ }
+}
+
+// Global singleton instance
+export const wsClient = new WebSocketClient();
+
+// React hook for WebSocket using the singleton
+export function useWebSocket(onMessage: MessageHandler, guildId?: string, onSessionExpired?: () => void) {
+ const [isConnected, setIsConnected] = useState(wsClient.isConnected);
+ const onMessageRef = useRef(onMessage);
+
+ useEffect(() => {
+ onMessageRef.current = onMessage;
+ }, [onMessage]);
+
+ useEffect(() => {
+ // Subscribe to connection changes
+ const unsubscribeConn = wsClient.addConnectionHandlers(
+ () => setIsConnected(true),
+ (event) => {
+ setIsConnected(false);
+ if (event && event.reason && (event.reason.includes("No user in session") || event.reason.includes("Rejected WS connection"))) {
+ if (onSessionExpired) {
+ onSessionExpired();
+ } else {
+ // Fallback if no handler provided (e.g. login page)
+ console.warn("Session expired handling not implemented in this context");
+ }
+ }
+ }
+ );
+
+ // Subscribe to messages
+ const unsubscribeMsg = wsClient.onMessage((data) => {
+ onMessageRef.current(data);
+ });
+
+ // Ensure we are connected
+ wsClient.connect(guildId);
+
+ // Heartbeat interval
+ const interval = setInterval(() => {
+ if (wsClient.isConnected) {
+ wsClient.send({type: 'PING'});
+ } else {
+ wsClient.connect(guildId); // Force check if somehow stuck
+ }
+ }, 15000);
+
+ return () => {
+ unsubscribeConn();
+ unsubscribeMsg();
+ clearInterval(interval);
+ };
+ }, []);
+
+ return {isConnected, ws: wsClient};
+}
diff --git a/src/dashboard/src/locales/zh-TW.json b/src/dashboard/src/locales/zh-TW.json
new file mode 100644
index 0000000..5f2b387
--- /dev/null
+++ b/src/dashboard/src/locales/zh-TW.json
@@ -0,0 +1,329 @@
+{
+ "app": {
+ "title": "狼人殺助手",
+ "subtitle": "管理員儀表板與遊戲管理器"
+ },
+ "login": {
+ "title": "狼人殺助手",
+ "subtitle": "管理員儀表板與遊戲管理器",
+ "loginButton": "使用 Discord 登入",
+ "restriction": "僅限狼人伺服器管理員"
+ },
+ "auth": {
+ "loggingIn": "登入中...",
+ "sessionExpiredTitle": "登入已過期",
+ "sessionExpiredMessage": "您的登入工作階段已過期,請重新登入。",
+ "loginAgain": "重新登入"
+ },
+ "sidebar": {
+ "dashboard": "儀表板",
+ "switchServer": "切換伺服器",
+ "gameSettings": "遊戲設定",
+ "integrationGuide": "整合指南",
+ "botConnected": "機器人已連接",
+ "botDisconnected": "機器人未連接",
+ "spectator": "上帝視角",
+ "spectatorView": "上帝視角 / 亡者國度",
+ "speechManager": "發言管理系統",
+ "signOut": "登出",
+ "viewAsSpectator": "以觀眾身分檢視 (預覽)",
+ "backToJudge": "返回法官模式"
+ },
+ "actions": {
+ "kill": "殺死"
+ },
+ "accessDenied": {
+ "title": "存取限制",
+ "message": "身為遊戲中的活躍玩家,為了確保遊戲公平性,您目前無法進入管理儀表板。",
+ "suggestion": "如果您是法官或上帝,請確保您的 Discord 帳號已擁有正確的身分組。",
+ "back": "返回伺服器列表"
+ },
+ "common": {
+ "cancel": "取消",
+ "confirm": "確認",
+ "none": "無",
+ "save": "儲存"
+ },
+ "game": {
+ "lastWords": "遺言"
+ },
+ "gameHeader": {
+ "currentPhase": "當前階段",
+ "timer": "計時器",
+ "startGame": "開始遊戲",
+ "nextPhase": "下一階段",
+ "lastWords": "遺言"
+ },
+ "phases": {
+ "LOBBY": "大廳",
+ "NIGHT": "夜晚",
+ "DAY": "白天",
+ "VOTING": "投票",
+ "GAME_OVER": "遊戲結束"
+ },
+ "players": {
+ "title": "玩家",
+ "alive": "存活",
+ "dead": "死亡",
+ "kill": "殺死",
+ "confirmKill": "確認殺死?",
+ "revive": "復活",
+ "edit": "編輯",
+ "transferPoliceDescription": "將警徽移交給另一位存活玩家。",
+ "selectTarget": "選擇對象...",
+ "noActionsAvailable": "此玩家目前沒有可用的特定操作。",
+ "killConfirmation": "確認處決",
+ "reviveRole": "復活身分: {role}",
+ "switchOrder": "切換身分順序"
+ },
+ "spectator": {
+ "title": "上帝視角 / 亡者國度",
+ "subtitle": "在此查看遊戲進度及各陣營勝利條件",
+ "wolfGoal": "狼人目標:屠戮神職",
+ "wolfGoalDesc": "殺死所有神職人員",
+ "godsLeft": "神職陣營狀況",
+ "godsLeftDesc": "剩餘存活神職",
+ "wolvesLeft": "狼人陣營狀況",
+ "wolvesLeftDesc": "剩餘存活狼人",
+ "villagersLeft": "平民陣營狀況",
+ "villagersLeftDesc": "剩餘存活平民",
+ "jbbLeft": "金寶寶陣營狀況",
+ "jbbLeftDesc": "剩餘存活金寶寶",
+ "godGoal": "好人目標:驅逐狼人",
+ "godGoalDesc": "放逐或殺死所有狼人",
+ "villagerGoal": "狼人目標:屠戮平民",
+ "villagerGoalDesc": "殺死所有平民",
+ "jbbGoal": "狼人目標:尋找金寶寶",
+ "jbbGoalDesc": "殺死所有金寶寶",
+ "winConditions": "詳細勝利條件",
+ "goodWinCondition": "好人陣營:所有狼人死亡",
+ "wolfWinConditionNormal": "狼人陣營:所有神職死亡 或 所有平民死亡",
+ "wolfWinConditionDouble": "狼人陣營:所有神職死亡 或 所有金寶寶死亡"
+ },
+ "roles": {
+ "title": "角色編輯",
+ "role": "角色",
+ "WEREWOLF": "狼人",
+ "VILLAGER": "村民",
+ "SEER": "預言家",
+ "WITCH": "女巫",
+ "HUNTER": "獵人",
+ "GUARD": "守衛",
+ "unknown": "未知身分"
+ },
+ "status": {
+ "sheriff": "警長",
+ "jinBaoBao": "金寶寶",
+ "protected": "已保護",
+ "poisoned": "已中毒",
+ "silenced": "已禁言"
+ },
+ "gameLog": {
+ "title": "遊戲日誌",
+ "placeholder": "輸入手動命令...",
+ "gameStarted": "遊戲已開始!",
+ "gamePaused": "遊戲計時器已被管理員暫停。",
+ "randomizeRoles": "正在隨機分配角色...",
+ "adminCommand": "管理員執行命令:/{action} 對 {player}",
+ "adminGlobalCommand": "管理員執行全域命令:/{action}",
+ "manualCommand": "手動命令:{cmd}"
+ },
+ "globalCommands": {
+ "title": "全域管理員命令",
+ "randomAssign": "隨機分配角色",
+ "startGame": "正式開始遊戲",
+ "forceReset": "強制重置",
+ "confirmReset": "確認重置?",
+ "gameFlow": "遊戲流程",
+ "voiceTimer": "語音與計時",
+ "adminRoles": "管理員身分"
+ },
+ "timer": {
+ "title": "手動計時器",
+ "start": "開始計時",
+ "minutes": "分",
+ "seconds": "秒"
+ },
+ "voice": {
+ "muteAll": "全體靜音",
+ "unmuteAll": "全體解除靜音"
+ },
+ "admin": {
+ "assignJudge": "指派法官",
+ "demoteJudge": "解除法官",
+ "forcePolice": "強制警長"
+ },
+ "serverSelector": {
+ "title": "選擇伺服器",
+ "subtitle": "選擇要管理的狼人殺遊戲伺服器",
+ "loading": "載入伺服器列表...",
+ "loadError": "無法載入伺服器列表",
+ "retry": "重試",
+ "noSessions": "目前沒有進行中的遊戲",
+ "noSessionsHint": "在 Discord 中使用指令建立遊戲後再回來",
+ "players": "位玩家",
+ "backToLogin": "返回登入",
+ "switching": "切換伺服器中..."
+ },
+ "settings": {
+ "general": "一般設定",
+ "muteAfterSpeech": "發言後靜音",
+ "muteAfterSpeechDesc": "玩家發言結束後自動將其靜音",
+ "doubleIdentities": "雙重身分",
+ "doubleIdentitiesDesc": "每位玩家獲得兩個角色",
+ "playerCount": "玩家人數設定",
+ "totalPlayers": "總玩家數",
+ "playerCountDesc": "調整此數值將會自動建立或刪除遊戲頻道。"
+ },
+ "buttons": {
+ "update": "更新"
+ },
+ "speechManager": {
+ "startAuto": "開始自動發言",
+ "startPoliceEnroll": "啟動警長參選",
+ "skip": "強制換人",
+ "interrupt": "終止流程",
+ "noActiveSpeech": "目前沒有正在進行的自動發言流程。",
+ "noActiveSpeechJudge": "目前沒有正在進行的自動發言流程。身為法官,您可以隨時開始新的流程。",
+ "activeSpeech": "發言進行中",
+ "autoProcess": "自動流程",
+ "speaking": "正在發言",
+ "waiting": "等待發言",
+ "noMoreSpeakers": "沒有更多發言者",
+ "interruptVote": "下台投票",
+ "preparing": "準備中...",
+ "policeUnenrollment": "警長退選",
+ "policeEnrollment": "警長參選",
+ "allowEnroll": "允許參選",
+ "allowUnEnroll": "允許退選",
+ "candidates": "參選名單",
+ "noCandidates": "目前無參選者...",
+ "waitingForPolice": "等待警長選擇發言順序...",
+ "waitingForPoliceSub": "警長正在選擇發言順序 (警上/警下)",
+ "forceUp": "強制往上",
+ "forceDown": "強制往下",
+ "judgeOverride": "法官強制操作"
+ },
+ "progressOverlay": {
+ "operationFailed": "操作失敗",
+ "processing": "正在處理請求...",
+ "complete": "完成!",
+ "unknownError": "發生未知錯誤",
+ "close": "關閉",
+ "ok": "確定",
+ "resetTitle": "重置遊戲"
+ },
+ "vote": {
+ "policeElection": "警長選舉",
+ "expelVote": "放逐投票",
+ "progress": "投票進行中",
+ "total": "總票數",
+ "count": "票",
+ "noVotes": "尚未有人投票",
+ "waiting": "等待 {count} 位玩家..."
+ },
+ "playerEdit": {
+ "title": "編輯玩家",
+ "subtitle": "修改 {player} 的角色與狀態",
+ "currentRoles": "目前角色",
+ "noRoles": "尚未分配角色",
+ "addRole": "新增角色",
+ "selectRole": "選擇角色...",
+ "add": "新增",
+ "removeRole": "移除角色",
+ "switchOrder": "交換順序",
+ "lockPosition": "鎖定位置",
+ "unlockPosition": "解鎖位置",
+ "positionLocked": "位置已鎖定",
+ "positionUnlocked": "位置未鎖定",
+ "close": "關閉"
+ },
+ "deathConfirm": {
+ "title": "確認處決",
+ "message": "確定要處決 {player} 嗎?",
+ "lastWordsOption": "允許遺言",
+ "cancel": "取消",
+ "confirm": "確認處決"
+ },
+ "gameSettings": {
+ "title": "遊戲設定",
+ "subtitle": "管理遊戲規則與行為",
+ "generalSettings": "一般設定",
+ "doubleIdentities": "雙重身分",
+ "doubleIdentitiesDesc": "每位玩家獲得兩個角色",
+ "muteAfterSpeech": "發言後靜音",
+ "muteAfterSpeechDesc": "玩家發言結束後自動靜音",
+ "roleManagement": "角色管理",
+ "roleManagementDesc": "新增或移除遊戲中的角色",
+ "currentRoles": "目前角色",
+ "noRoles": "尚未設定角色",
+ "addRole": "新增角色",
+ "selectRole": "選擇角色...",
+ "amount": "數量",
+ "add": "新增",
+ "remove": "移除",
+ "backToDashboard": "返回儀表板"
+ },
+ "settingsModal": {
+ "backendUrl": "後端伺服器網址",
+ "urlPlaceholder": "(保持空白以使用目前網域)",
+ "urlHint": "保持空白則自動使用目前來源。如果您在開發環境(如可以使用 127.0.0.1:8080),也可以手動輸入網址。",
+ "testConnection": "測試連接",
+ "testing": "測試中...",
+ "connectionSuccess": "連接成功",
+ "connectionFailed": "連接失敗"
+ },
+ "search": {
+ "placeholder": "搜尋玩家...",
+ "noResults": "找不到玩家"
+ },
+ "modal": {
+ "assignJudge": "指派法官",
+ "demoteJudge": "解除法官",
+ "forcePolice": "強制警長"
+ },
+ "tooltips": {
+ "skipSpeaker": "跳過當前發言者,換下一位",
+ "interruptSpeech": "終止整個發言流程",
+ "switchToLight": "切換到淺色模式",
+ "switchToDark": "切換到深色模式"
+ },
+ "messages": {
+ "unassigned": "未指派",
+ "player": "玩家",
+ "speaking": "發言中",
+ "progress": "進度",
+ "totalCount": "總數",
+ "noRolesConfigured": "尚無角色配置",
+ "selectOrEnterRole": "選擇或輸入角色名稱...",
+ "add": "新增",
+ "lockRoleOrder": "鎖定角色順序",
+ "autoMuteAfterSpeech": "發言後自動閉麥",
+ "autoMuteDesc": "玩家發言結束後自動將其靜音",
+ "roleSettings": "設定",
+ "randomAssignRoles": "隨機分配角色"
+ },
+ "errors": {
+ "actionFailed": "{action} 失敗",
+ "unknownError": "未知錯誤",
+ "error": "錯誤",
+ "resetFailed": "重置失敗",
+ "assignFailed": "分配失敗"
+ },
+ "overlayMessages": {
+ "resetting": "正在重置遊戲...",
+ "resetSuccess": "重置成功!遊戲已恢復到初始狀態。",
+ "requestingAssign": "正在向伺服器請求分配...",
+ "assignSuccess": "分配成功!所有玩家已收到身分通知。",
+ "updatingPlayerCount": "正在更新玩家人數並調整遊戲配置...",
+ "playerCountUpdateSuccess": "玩家人數更新成功。",
+ "processing": "處理中..."
+ },
+ "userRoles": {
+ "JUDGE": "法官",
+ "SPECTATOR": "上帝模式",
+ "PENDING": "等待中",
+ "BLOCKED": "玩家 (受限)",
+ "null": "未知"
+ }
+}
\ No newline at end of file
diff --git a/src/dashboard/src/main.tsx b/src/dashboard/src/main.tsx
new file mode 100644
index 0000000..8f2a9f7
--- /dev/null
+++ b/src/dashboard/src/main.tsx
@@ -0,0 +1,17 @@
+import {createRoot} from 'react-dom/client';
+import {BrowserRouter} from 'react-router-dom';
+import {ThemeProvider} from '@/lib/ThemeProvider';
+import {AuthProvider} from '@/features/auth/contexts/AuthContext';
+import App from './App';
+import './index.css';
+
+const root = createRoot(document.getElementById('root')!);
+root.render(
+
+
+
+
+
+
+
+);
diff --git a/src/dashboard/src/types/index.ts b/src/dashboard/src/types/index.ts
new file mode 100644
index 0000000..fbb9eb1
--- /dev/null
+++ b/src/dashboard/src/types/index.ts
@@ -0,0 +1,110 @@
+// Game state types matching backend Session structure
+export interface Session {
+ guildId: string;
+ guildName?: string;
+ guildIcon?: string;
+ doubleIdentities: boolean;
+ muteAfterSpeech: boolean;
+ hasAssignedRoles: boolean;
+ roles: string[];
+ players: SessionPlayer[];
+}
+
+export interface SessionPlayer {
+ id: string;
+ roleId: string;
+ channelId: string;
+ userId?: string;
+ name: string;
+ avatar: string;
+ roles: string[];
+ deadRoles: string[];
+ isAlive: boolean;
+ jinBaoBao: boolean;
+ police: boolean;
+ idiot: boolean;
+ duplicated: boolean;
+ rolePositionLocked: boolean;
+}
+
+export type Role = string;
+
+// Legacy types for reference (can be removed once migration is complete)
+export type GamePhase = 'LOBBY' | 'NIGHT' | 'DAY' | 'VOTING' | 'GAME_OVER';
+
+export interface GameState {
+ phase: GamePhase;
+ dayCount: number;
+ timerSeconds: number;
+ doubleIdentities?: boolean;
+ availableRoles?: string[];
+ players: Player[];
+ logs: LogEntry[];
+ speech?: SpeechState;
+ police?: PoliceState;
+ expel?: ExpelState;
+}
+
+export interface PoliceState {
+ state: 'NONE' | 'ENROLLMENT' | 'SPEECH' | 'UNENROLLMENT' | 'VOTING' | 'FINISHED';
+ stageEndTime?: number;
+ allowEnroll: boolean;
+ allowUnEnroll: boolean;
+ candidates: {
+ id: string; // Player ID (internal)
+ quit?: boolean;
+ voters: string[]; // List of User IDs (or Player IDs depending on backend mapping)
+ }[];
+}
+
+export interface ExpelState {
+ voting: boolean;
+ candidates: {
+ id: string;
+ quit?: boolean;
+ voters: string[];
+ }[];
+}
+
+export interface SpeechState {
+ order: string[]; // List of Player IDs (internal IDs)
+ currentSpeakerId?: string;
+ endTime: number;
+ totalTime: number;
+ isPaused?: boolean;
+ interruptVotes?: string[];
+}
+
+export interface Player {
+ id: string;
+ name: string;
+ userId?: string; // Discord User ID
+ username?: string; // Discord username
+ roles: string[]; // Array to support double identities
+ deadRoles: string[]; // Track which roles are dead
+ avatar: string;
+ isAlive: boolean;
+ isSheriff: boolean;
+ isJinBaoBao: boolean;
+ isProtected: boolean;
+ isPoisoned: boolean;
+ isSilenced: boolean;
+ isDuplicated?: boolean;
+ isJudge?: boolean;
+ rolePositionLocked?: boolean;
+}
+
+export interface LogEntry {
+ id: string;
+ timestamp: string;
+ message: string;
+ type: 'info' | 'action' | 'alert';
+}
+
+export interface User {
+ userId: string;
+ username: string;
+ avatar: string;
+ guildId: string;
+ role: 'JUDGE' | 'SPECTATOR' | 'BLOCKED' | 'PENDING';
+}
diff --git a/src/dashboard/src/utils/mockData.ts b/src/dashboard/src/utils/mockData.ts
new file mode 100644
index 0000000..5ae8d18
--- /dev/null
+++ b/src/dashboard/src/utils/mockData.ts
@@ -0,0 +1,28 @@
+import {Player} from '@/types';
+
+export const MOCK_AVATARS = [
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka',
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Zack',
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah',
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=John',
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Mila',
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Leo',
+ 'https://api.dicebear.com/7.x/avataaars/svg?seed=Kai',
+];
+
+export const INITIAL_PLAYERS: Player[] = Array.from({length: 8}).map((_, i) => ({
+ id: `p-${i}`,
+ discordId: `u-${i}`,
+ name: `Player ${i + 1}`,
+ avatar: MOCK_AVATARS[i],
+ roles: i === 0 ? ['WEREWOLF'] : i === 1 ? ['SEER'] : i === 2 ? ['WITCH'] : ['VILLAGER'],
+ deadRoles: [],
+ isAlive: true,
+ isSheriff: false,
+ isJinBaoBao: i === 3,
+ isProtected: false,
+ isPoisoned: false,
+ isSilenced: false,
+ hasVoted: false,
+}));
diff --git a/src/dashboard/tailwind.config.js b/src/dashboard/tailwind.config.js
new file mode 100644
index 0000000..019c249
--- /dev/null
+++ b/src/dashboard/tailwind.config.js
@@ -0,0 +1,12 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ darkMode: 'class',
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
\ No newline at end of file
diff --git a/src/dashboard/tsconfig.json b/src/dashboard/tsconfig.json
new file mode 100644
index 0000000..ec7dafc
--- /dev/null
+++ b/src/dashboard/tsconfig.json
@@ -0,0 +1,39 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": [
+ "ES2020",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ]
+ },
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "jsx": "react-jsx"
+ },
+ "include": [
+ "."
+ ],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/dashboard/tsconfig.node.json b/src/dashboard/tsconfig.node.json
new file mode 100644
index 0000000..73dbb0b
--- /dev/null
+++ b/src/dashboard/tsconfig.node.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": [
+ "vite.config.ts"
+ ]
+}
\ No newline at end of file
diff --git a/src/dashboard/vite.config.ts b/src/dashboard/vite.config.ts
new file mode 100644
index 0000000..1ecb29c
--- /dev/null
+++ b/src/dashboard/vite.config.ts
@@ -0,0 +1,26 @@
+import {defineConfig} from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8080',
+ changeOrigin: true
+ },
+ '/ws': {
+ target: 'ws://localhost:8080',
+ ws: true
+ }
+ },
+ allowedHosts: ['wolf.robothanzo.dev']
+ }
+})
\ No newline at end of file
diff --git a/src/dashboard/yarn.lock b/src/dashboard/yarn.lock
new file mode 100644
index 0000000..42760bf
--- /dev/null
+++ b/src/dashboard/yarn.lock
@@ -0,0 +1,1239 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@alloc/quick-lru@^5.2.0":
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30"
+ integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
+
+"@babel/code-frame@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.28.6.tgz#72499312ec58b1e2245ba4a4f550c132be4982f7"
+ integrity sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==
+ dependencies:
+ "@babel/helper-validator-identifier" "^7.28.5"
+ js-tokens "^4.0.0"
+ picocolors "^1.1.1"
+
+"@babel/compat-data@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.6.tgz#103f466803fa0f059e82ccac271475470570d74c"
+ integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==
+
+"@babel/core@^7.28.0":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.6.tgz#531bf883a1126e53501ba46eb3bb414047af507f"
+ integrity sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==
+ dependencies:
+ "@babel/code-frame" "^7.28.6"
+ "@babel/generator" "^7.28.6"
+ "@babel/helper-compilation-targets" "^7.28.6"
+ "@babel/helper-module-transforms" "^7.28.6"
+ "@babel/helpers" "^7.28.6"
+ "@babel/parser" "^7.28.6"
+ "@babel/template" "^7.28.6"
+ "@babel/traverse" "^7.28.6"
+ "@babel/types" "^7.28.6"
+ "@jridgewell/remapping" "^2.3.5"
+ convert-source-map "^2.0.0"
+ debug "^4.1.0"
+ gensync "^1.0.0-beta.2"
+ json5 "^2.2.3"
+ semver "^6.3.1"
+
+"@babel/generator@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.6.tgz#48dcc65d98fcc8626a48f72b62e263d25fc3c3f1"
+ integrity sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==
+ dependencies:
+ "@babel/parser" "^7.28.6"
+ "@babel/types" "^7.28.6"
+ "@jridgewell/gen-mapping" "^0.3.12"
+ "@jridgewell/trace-mapping" "^0.3.28"
+ jsesc "^3.0.2"
+
+"@babel/helper-compilation-targets@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25"
+ integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==
+ dependencies:
+ "@babel/compat-data" "^7.28.6"
+ "@babel/helper-validator-option" "^7.27.1"
+ browserslist "^4.24.0"
+ lru-cache "^5.1.1"
+ semver "^6.3.1"
+
+"@babel/helper-globals@^7.28.0":
+ version "7.28.0"
+ resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674"
+ integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==
+
+"@babel/helper-module-imports@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz#60632cbd6ffb70b22823187201116762a03e2d5c"
+ integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==
+ dependencies:
+ "@babel/traverse" "^7.28.6"
+ "@babel/types" "^7.28.6"
+
+"@babel/helper-module-transforms@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e"
+ integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==
+ dependencies:
+ "@babel/helper-module-imports" "^7.28.6"
+ "@babel/helper-validator-identifier" "^7.28.5"
+ "@babel/traverse" "^7.28.6"
+
+"@babel/helper-plugin-utils@^7.27.1":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8"
+ integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==
+
+"@babel/helper-string-parser@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
+ integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
+
+"@babel/helper-validator-identifier@^7.28.5":
+ version "7.28.5"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
+ integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
+
+"@babel/helper-validator-option@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f"
+ integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==
+
+"@babel/helpers@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7"
+ integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==
+ dependencies:
+ "@babel/template" "^7.28.6"
+ "@babel/types" "^7.28.6"
+
+"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.6.tgz#f01a8885b7fa1e56dd8a155130226cd698ef13fd"
+ integrity sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==
+ dependencies:
+ "@babel/types" "^7.28.6"
+
+"@babel/plugin-transform-react-jsx-self@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz#af678d8506acf52c577cac73ff7fe6615c85fc92"
+ integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-react-jsx-source@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz#dcfe2c24094bb757bf73960374e7c55e434f19f0"
+ integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/template@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57"
+ integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==
+ dependencies:
+ "@babel/code-frame" "^7.28.6"
+ "@babel/parser" "^7.28.6"
+ "@babel/types" "^7.28.6"
+
+"@babel/traverse@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.6.tgz#871ddc79a80599a5030c53b1cc48cbe3a5583c2e"
+ integrity sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==
+ dependencies:
+ "@babel/code-frame" "^7.28.6"
+ "@babel/generator" "^7.28.6"
+ "@babel/helper-globals" "^7.28.0"
+ "@babel/parser" "^7.28.6"
+ "@babel/template" "^7.28.6"
+ "@babel/types" "^7.28.6"
+ debug "^4.3.1"
+
+"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.28.2", "@babel/types@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.6.tgz#c3e9377f1b155005bcc4c46020e7e394e13089df"
+ integrity sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==
+ dependencies:
+ "@babel/helper-string-parser" "^7.27.1"
+ "@babel/helper-validator-identifier" "^7.28.5"
+
+"@esbuild/aix-ppc64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
+ integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==
+
+"@esbuild/android-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052"
+ integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
+
+"@esbuild/android-arm@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28"
+ integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==
+
+"@esbuild/android-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e"
+ integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==
+
+"@esbuild/darwin-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a"
+ integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==
+
+"@esbuild/darwin-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22"
+ integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==
+
+"@esbuild/freebsd-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e"
+ integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==
+
+"@esbuild/freebsd-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261"
+ integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==
+
+"@esbuild/linux-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b"
+ integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
+
+"@esbuild/linux-arm@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9"
+ integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==
+
+"@esbuild/linux-ia32@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2"
+ integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==
+
+"@esbuild/linux-loong64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df"
+ integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==
+
+"@esbuild/linux-mips64el@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe"
+ integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==
+
+"@esbuild/linux-ppc64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4"
+ integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==
+
+"@esbuild/linux-riscv64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc"
+ integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==
+
+"@esbuild/linux-s390x@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de"
+ integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==
+
+"@esbuild/linux-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0"
+ integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==
+
+"@esbuild/netbsd-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047"
+ integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==
+
+"@esbuild/openbsd-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70"
+ integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==
+
+"@esbuild/sunos-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b"
+ integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==
+
+"@esbuild/win32-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d"
+ integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==
+
+"@esbuild/win32-ia32@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b"
+ integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==
+
+"@esbuild/win32-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c"
+ integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==
+
+"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5":
+ version "0.3.13"
+ resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f"
+ integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==
+ dependencies:
+ "@jridgewell/sourcemap-codec" "^1.5.0"
+ "@jridgewell/trace-mapping" "^0.3.24"
+
+"@jridgewell/remapping@^2.3.5":
+ version "2.3.5"
+ resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1"
+ integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==
+ dependencies:
+ "@jridgewell/gen-mapping" "^0.3.5"
+ "@jridgewell/trace-mapping" "^0.3.24"
+
+"@jridgewell/resolve-uri@^3.1.0":
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
+ integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
+
+"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0":
+ version "1.5.5"
+ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
+ integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
+
+"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28":
+ version "0.3.31"
+ resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0"
+ integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==
+ dependencies:
+ "@jridgewell/resolve-uri" "^3.1.0"
+ "@jridgewell/sourcemap-codec" "^1.4.14"
+
+"@nodelib/fs.scandir@2.1.5":
+ version "2.1.5"
+ resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
+ integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
+ dependencies:
+ "@nodelib/fs.stat" "2.0.5"
+ run-parallel "^1.1.9"
+
+"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
+ integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
+
+"@nodelib/fs.walk@^1.2.3":
+ version "1.2.8"
+ resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
+ integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
+ dependencies:
+ "@nodelib/fs.scandir" "2.1.5"
+ fastq "^1.6.0"
+
+"@rolldown/pluginutils@1.0.0-beta.27":
+ version "1.0.0-beta.27"
+ resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz#47d2bf4cef6d470b22f5831b420f8964e0bf755f"
+ integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==
+
+"@rollup/rollup-android-arm-eabi@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088"
+ integrity sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==
+
+"@rollup/rollup-android-arm64@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz#10bd0382b73592beee6e9800a69401a29da625c4"
+ integrity sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==
+
+"@rollup/rollup-darwin-arm64@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz#1e99ab04c0b8c619dd7bbde725ba2b87b55bfd81"
+ integrity sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==
+
+"@rollup/rollup-darwin-x64@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz#69e741aeb2839d2e8f0da2ce7a33d8bd23632423"
+ integrity sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==
+
+"@rollup/rollup-freebsd-arm64@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz#3736c232a999c7bef7131355d83ebdf9651a0839"
+ integrity sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==
+
+"@rollup/rollup-freebsd-x64@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz#227dcb8f466684070169942bd3998901c9bfc065"
+ integrity sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==
+
+"@rollup/rollup-linux-arm-gnueabihf@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz#ba004b30df31b724f99ce66e7128248bea17cb0c"
+ integrity sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==
+
+"@rollup/rollup-linux-arm-musleabihf@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz#6929f3e07be6b6da5991f63c6b68b3e473d0a65a"
+ integrity sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==
+
+"@rollup/rollup-linux-arm64-gnu@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz#06e89fd4a25d21fe5575d60b6f913c0e65297bfa"
+ integrity sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==
+
+"@rollup/rollup-linux-arm64-musl@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz#fddabf395b90990d5194038e6cd8c00156ed8ac0"
+ integrity sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==
+
+"@rollup/rollup-linux-loong64-gnu@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz#04c10bb764bbf09a3c1bd90432e92f58d6603c36"
+ integrity sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==
+
+"@rollup/rollup-linux-loong64-musl@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz#f2450361790de80581d8687ea19142d8a4de5c0f"
+ integrity sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==
+
+"@rollup/rollup-linux-ppc64-gnu@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz#0474f4667259e407eee1a6d38e29041b708f6a30"
+ integrity sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==
+
+"@rollup/rollup-linux-ppc64-musl@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz#9f32074819eeb1ddbe51f50ea9dcd61a6745ec33"
+ integrity sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==
+
+"@rollup/rollup-linux-riscv64-gnu@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz#3fdb9d4b1e29fb6b6a6da9f15654d42eb77b99b2"
+ integrity sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==
+
+"@rollup/rollup-linux-riscv64-musl@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz#1de780d64e6be0e3e8762035c22e0d8ea68df8ed"
+ integrity sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==
+
+"@rollup/rollup-linux-s390x-gnu@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz#1da022ffd2d9e9f0fd8344ea49e113001fbcac64"
+ integrity sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==
+
+"@rollup/rollup-linux-x64-gnu@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz#78c16eef9520bd10e1ea7a112593bb58e2842622"
+ integrity sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==
+
+"@rollup/rollup-linux-x64-musl@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz#a7598591b4d9af96cb3167b50a5bf1e02dfea06c"
+ integrity sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==
+
+"@rollup/rollup-openbsd-x64@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz#c51d48c07cd6c466560e5bed934aec688ce02614"
+ integrity sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==
+
+"@rollup/rollup-openharmony-arm64@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz#f09921d0b2a0b60afbf3586d2a7a7f208ba6df17"
+ integrity sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==
+
+"@rollup/rollup-win32-arm64-msvc@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz#08d491717135376e4a99529821c94ecd433d5b36"
+ integrity sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==
+
+"@rollup/rollup-win32-ia32-msvc@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz#b0c12aac1104a8b8f26a5e0098e5facbb3e3964a"
+ integrity sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==
+
+"@rollup/rollup-win32-x64-gnu@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz#b9cccef26f5e6fdc013bf3c0911a3c77428509d0"
+ integrity sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==
+
+"@rollup/rollup-win32-x64-msvc@4.57.1":
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz#a03348e7b559c792b6277cc58874b89ef46e1e72"
+ integrity sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==
+
+"@types/babel__core@^7.20.5":
+ version "7.20.5"
+ resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017"
+ integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==
+ dependencies:
+ "@babel/parser" "^7.20.7"
+ "@babel/types" "^7.20.7"
+ "@types/babel__generator" "*"
+ "@types/babel__template" "*"
+ "@types/babel__traverse" "*"
+
+"@types/babel__generator@*":
+ version "7.27.0"
+ resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9"
+ integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==
+ dependencies:
+ "@babel/types" "^7.0.0"
+
+"@types/babel__template@*":
+ version "7.4.4"
+ resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f"
+ integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==
+ dependencies:
+ "@babel/parser" "^7.1.0"
+ "@babel/types" "^7.0.0"
+
+"@types/babel__traverse@*":
+ version "7.28.0"
+ resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74"
+ integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==
+ dependencies:
+ "@babel/types" "^7.28.2"
+
+"@types/estree@1.0.8":
+ version "1.0.8"
+ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
+ integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
+
+"@types/node@^25.2.0":
+ version "25.2.0"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-25.2.0.tgz#015b7d228470c1dcbfc17fe9c63039d216b4d782"
+ integrity sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==
+ dependencies:
+ undici-types "~7.16.0"
+
+"@types/prop-types@*":
+ version "15.7.15"
+ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7"
+ integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==
+
+"@types/react-dom@^18.2.21":
+ version "18.3.7"
+ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f"
+ integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==
+
+"@types/react@^18.2.64":
+ version "18.3.27"
+ resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.27.tgz#74a3b590ea183983dc65a474dc17553ae1415c34"
+ integrity sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==
+ dependencies:
+ "@types/prop-types" "*"
+ csstype "^3.2.2"
+
+"@vitejs/plugin-react@^4.2.1":
+ version "4.7.0"
+ resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz#647af4e7bb75ad3add578e762ad984b90f4a24b9"
+ integrity sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==
+ dependencies:
+ "@babel/core" "^7.28.0"
+ "@babel/plugin-transform-react-jsx-self" "^7.27.1"
+ "@babel/plugin-transform-react-jsx-source" "^7.27.1"
+ "@rolldown/pluginutils" "1.0.0-beta.27"
+ "@types/babel__core" "^7.20.5"
+ react-refresh "^0.17.0"
+
+any-promise@^1.0.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
+ integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==
+
+anymatch@~3.1.2:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
+ integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
+ dependencies:
+ normalize-path "^3.0.0"
+ picomatch "^2.0.4"
+
+arg@^5.0.2:
+ version "5.0.2"
+ resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
+ integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==
+
+autoprefixer@^10.4.18:
+ version "10.4.23"
+ resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.23.tgz#c6aa6db8e7376fcd900f9fd79d143ceebad8c4e6"
+ integrity sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==
+ dependencies:
+ browserslist "^4.28.1"
+ caniuse-lite "^1.0.30001760"
+ fraction.js "^5.3.4"
+ picocolors "^1.1.1"
+ postcss-value-parser "^4.2.0"
+
+baseline-browser-mapping@^2.9.0:
+ version "2.9.19"
+ resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488"
+ integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==
+
+binary-extensions@^2.0.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"
+ integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==
+
+braces@^3.0.3, braces@~3.0.2:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
+ integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
+ dependencies:
+ fill-range "^7.1.1"
+
+browserslist@^4.24.0, browserslist@^4.28.1:
+ version "4.28.1"
+ resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95"
+ integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==
+ dependencies:
+ baseline-browser-mapping "^2.9.0"
+ caniuse-lite "^1.0.30001759"
+ electron-to-chromium "^1.5.263"
+ node-releases "^2.0.27"
+ update-browserslist-db "^1.2.0"
+
+camelcase-css@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
+ integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
+
+caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001760:
+ version "1.0.30001766"
+ resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a"
+ integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==
+
+chokidar@^3.6.0:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
+ integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
+ dependencies:
+ anymatch "~3.1.2"
+ braces "~3.0.2"
+ glob-parent "~5.1.2"
+ is-binary-path "~2.1.0"
+ is-glob "~4.0.1"
+ normalize-path "~3.0.0"
+ readdirp "~3.6.0"
+ optionalDependencies:
+ fsevents "~2.3.2"
+
+commander@^4.0.0:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
+ integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
+
+convert-source-map@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
+ integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
+
+cookie@^1.0.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
+ integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
+
+cssesc@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
+ integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
+
+csstype@^3.2.2:
+ version "3.2.3"
+ resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
+ integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
+
+debug@^4.1.0, debug@^4.3.1:
+ version "4.4.3"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
+ integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
+ dependencies:
+ ms "^2.1.3"
+
+didyoumean@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
+ integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
+
+dlv@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
+ integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
+
+electron-to-chromium@^1.5.263:
+ version "1.5.283"
+ resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz#51d492c37c2d845a0dccb113fe594880c8616de8"
+ integrity sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==
+
+esbuild@^0.21.3:
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d"
+ integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==
+ optionalDependencies:
+ "@esbuild/aix-ppc64" "0.21.5"
+ "@esbuild/android-arm" "0.21.5"
+ "@esbuild/android-arm64" "0.21.5"
+ "@esbuild/android-x64" "0.21.5"
+ "@esbuild/darwin-arm64" "0.21.5"
+ "@esbuild/darwin-x64" "0.21.5"
+ "@esbuild/freebsd-arm64" "0.21.5"
+ "@esbuild/freebsd-x64" "0.21.5"
+ "@esbuild/linux-arm" "0.21.5"
+ "@esbuild/linux-arm64" "0.21.5"
+ "@esbuild/linux-ia32" "0.21.5"
+ "@esbuild/linux-loong64" "0.21.5"
+ "@esbuild/linux-mips64el" "0.21.5"
+ "@esbuild/linux-ppc64" "0.21.5"
+ "@esbuild/linux-riscv64" "0.21.5"
+ "@esbuild/linux-s390x" "0.21.5"
+ "@esbuild/linux-x64" "0.21.5"
+ "@esbuild/netbsd-x64" "0.21.5"
+ "@esbuild/openbsd-x64" "0.21.5"
+ "@esbuild/sunos-x64" "0.21.5"
+ "@esbuild/win32-arm64" "0.21.5"
+ "@esbuild/win32-ia32" "0.21.5"
+ "@esbuild/win32-x64" "0.21.5"
+
+escalade@^3.2.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
+ integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
+
+fast-glob@^3.3.2:
+ version "3.3.3"
+ resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
+ integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
+ dependencies:
+ "@nodelib/fs.stat" "^2.0.2"
+ "@nodelib/fs.walk" "^1.2.3"
+ glob-parent "^5.1.2"
+ merge2 "^1.3.0"
+ micromatch "^4.0.8"
+
+fastq@^1.6.0:
+ version "1.20.1"
+ resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675"
+ integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==
+ dependencies:
+ reusify "^1.0.4"
+
+fdir@^6.5.0:
+ version "6.5.0"
+ resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
+ integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
+
+fill-range@^7.1.1:
+ version "7.1.1"
+ resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
+ integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
+ dependencies:
+ to-regex-range "^5.0.1"
+
+fraction.js@^5.3.4:
+ version "5.3.4"
+ resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a"
+ integrity sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==
+
+fsevents@~2.3.2, fsevents@~2.3.3:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+ integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
+function-bind@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
+ integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
+
+gensync@^1.0.0-beta.2:
+ version "1.0.0-beta.2"
+ resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
+ integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
+
+glob-parent@^5.1.2, glob-parent@~5.1.2:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
+ integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
+ dependencies:
+ is-glob "^4.0.1"
+
+glob-parent@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
+ integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
+ dependencies:
+ is-glob "^4.0.3"
+
+hasown@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
+ integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
+ dependencies:
+ function-bind "^1.1.2"
+
+is-binary-path@~2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+ integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+ dependencies:
+ binary-extensions "^2.0.0"
+
+is-core-module@^2.16.1:
+ version "2.16.1"
+ resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4"
+ integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==
+ dependencies:
+ hasown "^2.0.2"
+
+is-extglob@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+ integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
+
+is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+ integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+ dependencies:
+ is-extglob "^2.1.1"
+
+is-number@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+ integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
+jiti@^1.21.7:
+ version "1.21.7"
+ resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9"
+ integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
+
+"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+ integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+
+jsesc@^3.0.2:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d"
+ integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
+
+json5@^2.2.3:
+ version "2.2.3"
+ resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
+ integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
+
+lilconfig@^3.1.1, lilconfig@^3.1.3:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4"
+ integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==
+
+lines-and-columns@^1.1.6:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
+ integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
+
+loose-envify@^1.1.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
+ integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
+ dependencies:
+ js-tokens "^3.0.0 || ^4.0.0"
+
+lru-cache@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
+ integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==
+ dependencies:
+ yallist "^3.0.2"
+
+lucide-react@^0.344.0:
+ version "0.344.0"
+ resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.344.0.tgz#fcbc7cf855e6baedbc14aab6ddca09b7c1afc46d"
+ integrity sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==
+
+merge2@^1.3.0:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
+ integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
+
+micromatch@^4.0.8:
+ version "4.0.8"
+ resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
+ integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
+ dependencies:
+ braces "^3.0.3"
+ picomatch "^2.3.1"
+
+ms@^2.1.3:
+ version "2.1.3"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+ integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
+mz@^2.7.0:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
+ integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
+ dependencies:
+ any-promise "^1.0.0"
+ object-assign "^4.0.1"
+ thenify-all "^1.0.0"
+
+nanoid@^3.3.11:
+ version "3.3.11"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
+ integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
+
+node-releases@^2.0.27:
+ version "2.0.27"
+ resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e"
+ integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
+
+normalize-path@^3.0.0, normalize-path@~3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+ integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+object-assign@^4.0.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+ integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
+
+object-hash@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
+ integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
+
+path-parse@^1.0.7:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
+ integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
+
+picocolors@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
+ integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
+
+picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+ integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+
+picomatch@^4.0.3:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
+ integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
+
+pify@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
+ integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
+
+pirates@^4.0.1:
+ version "4.0.7"
+ resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22"
+ integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==
+
+postcss-import@^15.1.0:
+ version "15.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70"
+ integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==
+ dependencies:
+ postcss-value-parser "^4.0.0"
+ read-cache "^1.0.0"
+ resolve "^1.1.7"
+
+postcss-js@^4.0.1:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.1.0.tgz#003b63c6edde948766e40f3daf7e997ae43a5ce6"
+ integrity sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==
+ dependencies:
+ camelcase-css "^2.0.1"
+
+"postcss-load-config@^4.0.2 || ^5.0 || ^6.0":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz#6fd7dcd8ae89badcf1b2d644489cbabf83aa8096"
+ integrity sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==
+ dependencies:
+ lilconfig "^3.1.1"
+
+postcss-nested@^6.2.0:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.2.0.tgz#4c2d22ab5f20b9cb61e2c5c5915950784d068131"
+ integrity sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==
+ dependencies:
+ postcss-selector-parser "^6.1.1"
+
+postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2:
+ version "6.1.2"
+ resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de"
+ integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==
+ dependencies:
+ cssesc "^3.0.0"
+ util-deprecate "^1.0.2"
+
+postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
+ integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
+
+postcss@^8.4.35, postcss@^8.4.43, postcss@^8.4.47:
+ version "8.5.6"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c"
+ integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==
+ dependencies:
+ nanoid "^3.3.11"
+ picocolors "^1.1.1"
+ source-map-js "^1.2.1"
+
+queue-microtask@^1.2.2:
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
+ integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+
+react-dom@^18.2.0:
+ version "18.3.1"
+ resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
+ integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
+ dependencies:
+ loose-envify "^1.1.0"
+ scheduler "^0.23.2"
+
+react-refresh@^0.17.0:
+ version "0.17.0"
+ resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53"
+ integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==
+
+react-router-dom@^7.13.0:
+ version "7.13.0"
+ resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.13.0.tgz#8b5f7204fadca680f0e94f207c163f0dcd1cfdf5"
+ integrity sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==
+ dependencies:
+ react-router "7.13.0"
+
+react-router@7.13.0:
+ version "7.13.0"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.13.0.tgz#de9484aee764f4f65b93275836ff5944d7f5bd3b"
+ integrity sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==
+ dependencies:
+ cookie "^1.0.1"
+ set-cookie-parser "^2.6.0"
+
+react@^18.2.0:
+ version "18.3.1"
+ resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
+ integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
+ dependencies:
+ loose-envify "^1.1.0"
+
+read-cache@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
+ integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==
+ dependencies:
+ pify "^2.3.0"
+
+readdirp@~3.6.0:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+ integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
+ dependencies:
+ picomatch "^2.2.1"
+
+resolve@^1.1.7, resolve@^1.22.8:
+ version "1.22.11"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262"
+ integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==
+ dependencies:
+ is-core-module "^2.16.1"
+ path-parse "^1.0.7"
+ supports-preserve-symlinks-flag "^1.0.0"
+
+reusify@^1.0.4:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
+ integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
+
+rollup@^4.20.0:
+ version "4.57.1"
+ resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.1.tgz#947f70baca32db2b9c594267fe9150aa316e5a88"
+ integrity sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==
+ dependencies:
+ "@types/estree" "1.0.8"
+ optionalDependencies:
+ "@rollup/rollup-android-arm-eabi" "4.57.1"
+ "@rollup/rollup-android-arm64" "4.57.1"
+ "@rollup/rollup-darwin-arm64" "4.57.1"
+ "@rollup/rollup-darwin-x64" "4.57.1"
+ "@rollup/rollup-freebsd-arm64" "4.57.1"
+ "@rollup/rollup-freebsd-x64" "4.57.1"
+ "@rollup/rollup-linux-arm-gnueabihf" "4.57.1"
+ "@rollup/rollup-linux-arm-musleabihf" "4.57.1"
+ "@rollup/rollup-linux-arm64-gnu" "4.57.1"
+ "@rollup/rollup-linux-arm64-musl" "4.57.1"
+ "@rollup/rollup-linux-loong64-gnu" "4.57.1"
+ "@rollup/rollup-linux-loong64-musl" "4.57.1"
+ "@rollup/rollup-linux-ppc64-gnu" "4.57.1"
+ "@rollup/rollup-linux-ppc64-musl" "4.57.1"
+ "@rollup/rollup-linux-riscv64-gnu" "4.57.1"
+ "@rollup/rollup-linux-riscv64-musl" "4.57.1"
+ "@rollup/rollup-linux-s390x-gnu" "4.57.1"
+ "@rollup/rollup-linux-x64-gnu" "4.57.1"
+ "@rollup/rollup-linux-x64-musl" "4.57.1"
+ "@rollup/rollup-openbsd-x64" "4.57.1"
+ "@rollup/rollup-openharmony-arm64" "4.57.1"
+ "@rollup/rollup-win32-arm64-msvc" "4.57.1"
+ "@rollup/rollup-win32-ia32-msvc" "4.57.1"
+ "@rollup/rollup-win32-x64-gnu" "4.57.1"
+ "@rollup/rollup-win32-x64-msvc" "4.57.1"
+ fsevents "~2.3.2"
+
+run-parallel@^1.1.9:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
+ integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
+ dependencies:
+ queue-microtask "^1.2.2"
+
+scheduler@^0.23.2:
+ version "0.23.2"
+ resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
+ integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
+ dependencies:
+ loose-envify "^1.1.0"
+
+semver@^6.3.1:
+ version "6.3.1"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
+ integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
+
+set-cookie-parser@^2.6.0:
+ version "2.7.2"
+ resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68"
+ integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==
+
+source-map-js@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
+ integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
+
+sucrase@^3.35.0:
+ version "3.35.1"
+ resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.1.tgz#4619ea50393fe8bd0ae5071c26abd9b2e346bfe1"
+ integrity sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==
+ dependencies:
+ "@jridgewell/gen-mapping" "^0.3.2"
+ commander "^4.0.0"
+ lines-and-columns "^1.1.6"
+ mz "^2.7.0"
+ pirates "^4.0.1"
+ tinyglobby "^0.2.11"
+ ts-interface-checker "^0.1.9"
+
+supports-preserve-symlinks-flag@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+ integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
+tailwindcss@^3.4.1:
+ version "3.4.19"
+ resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.19.tgz#af2a0a4ae302d52ebe078b6775e799e132500ee2"
+ integrity sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==
+ dependencies:
+ "@alloc/quick-lru" "^5.2.0"
+ arg "^5.0.2"
+ chokidar "^3.6.0"
+ didyoumean "^1.2.2"
+ dlv "^1.1.3"
+ fast-glob "^3.3.2"
+ glob-parent "^6.0.2"
+ is-glob "^4.0.3"
+ jiti "^1.21.7"
+ lilconfig "^3.1.3"
+ micromatch "^4.0.8"
+ normalize-path "^3.0.0"
+ object-hash "^3.0.0"
+ picocolors "^1.1.1"
+ postcss "^8.4.47"
+ postcss-import "^15.1.0"
+ postcss-js "^4.0.1"
+ postcss-load-config "^4.0.2 || ^5.0 || ^6.0"
+ postcss-nested "^6.2.0"
+ postcss-selector-parser "^6.1.2"
+ resolve "^1.22.8"
+ sucrase "^3.35.0"
+
+thenify-all@^1.0.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
+ integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==
+ dependencies:
+ thenify ">= 3.1.0 < 4"
+
+"thenify@>= 3.1.0 < 4":
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
+ integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
+ dependencies:
+ any-promise "^1.0.0"
+
+tinyglobby@^0.2.11:
+ version "0.2.15"
+ resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
+ integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
+ dependencies:
+ fdir "^6.5.0"
+ picomatch "^4.0.3"
+
+to-regex-range@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+ integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+ dependencies:
+ is-number "^7.0.0"
+
+ts-interface-checker@^0.1.9:
+ version "0.1.13"
+ resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
+ integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
+
+typescript@^5.2.2:
+ version "5.9.3"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
+ integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
+
+undici-types@~7.16.0:
+ version "7.16.0"
+ resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
+ integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
+
+update-browserslist-db@^1.2.0:
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
+ integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==
+ dependencies:
+ escalade "^3.2.0"
+ picocolors "^1.1.1"
+
+util-deprecate@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+ integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+
+vite@^5.1.6:
+ version "5.4.21"
+ resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027"
+ integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==
+ dependencies:
+ esbuild "^0.21.3"
+ postcss "^8.4.43"
+ rollup "^4.20.0"
+ optionalDependencies:
+ fsevents "~2.3.3"
+
+yallist@^3.0.2:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
+ integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
diff --git a/src/main/java/dev/robothanzo/werewolf/WerewolfApplication.java b/src/main/java/dev/robothanzo/werewolf/WerewolfApplication.java
new file mode 100644
index 0000000..ac99992
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/WerewolfApplication.java
@@ -0,0 +1,120 @@
+package dev.robothanzo.werewolf;
+
+import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
+import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
+import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers;
+import dev.robothanzo.werewolf.service.DiscordService;
+import dev.robothanzo.werewolf.service.GameSessionService;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import net.dv8tion.jda.api.JDA;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.stereotype.Component;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.zip.ZipEntry;
+
+@Slf4j
+@SpringBootApplication
+@EnableScheduling
+public class WerewolfApplication {
+
+ public static final long AUTHOR = 466769036122783744L;
+ public static final List SERVER_CREATORS = List.of(466769036122783744L, 616590798989033502L,
+ 451672040227864587L);
+ public static final List ROLES = List.of(
+ "狼人", "女巫", "獵人", "預言家", "平民", "狼王", "狼美人", "白狼王", "夢魘", "混血兒", "守衛", "騎士", "白癡",
+ "守墓人", "魔術師", "黑市商人", "邱比特", "盜賊", "石像鬼", "狼兄", "狼弟", "複製人", "血月使者", "惡靈騎士", "通靈師", "機械狼", "獵魔人");
+
+ // Static bridges for legacy code
+ public static JDA jda;
+ public static GameSessionService gameSessionService;
+ public static dev.robothanzo.werewolf.service.RoleService roleService;
+ public static dev.robothanzo.werewolf.service.GameActionService gameActionService;
+ public static dev.robothanzo.werewolf.service.PoliceService policeService;
+ public static dev.robothanzo.werewolf.service.PlayerService playerService;
+ public static dev.robothanzo.werewolf.service.SpeechService speechService;
+ public static AudioPlayerManager playerManager = new DefaultAudioPlayerManager();
+
+ static void main(String[] args) {
+ // Extract sounds before Spring starts
+ extractSoundFiles();
+ SpringApplication.run(WerewolfApplication.class, args);
+ }
+
+ // Component to populate static fields from Spring Context
+ @Component
+ @RequiredArgsConstructor
+ static class StaticBridge {
+ private final GameSessionService gameSessionServiceBean;
+ private final DiscordService discordServiceBean;
+ private final dev.robothanzo.werewolf.service.RoleService roleServiceBean;
+ private final dev.robothanzo.werewolf.service.GameActionService gameActionServiceBean;
+ private final dev.robothanzo.werewolf.service.PoliceService policeServiceBean;
+ private final dev.robothanzo.werewolf.service.PlayerService playerServiceBean;
+ private final dev.robothanzo.werewolf.service.SpeechService speechServiceBean;
+
+ @PostConstruct
+ public void init() {
+ log.info("Initializing Static Bridge...");
+ dev.robothanzo.werewolf.database.Database.initDatabase(); // Initialize legacy DB
+ WerewolfApplication.gameSessionService = gameSessionServiceBean;
+ WerewolfApplication.roleService = roleServiceBean;
+ WerewolfApplication.gameActionService = gameActionServiceBean;
+ WerewolfApplication.policeService = policeServiceBean;
+ WerewolfApplication.playerService = playerServiceBean;
+ WerewolfApplication.speechService = speechServiceBean;
+ WerewolfApplication.jda = discordServiceBean.getJDA();
+
+ // AudioPlayerManager setup if needed
+ AudioSourceManagers.registerRemoteSources(playerManager);
+ AudioSourceManagers.registerLocalSource(playerManager);
+ }
+ }
+
+ public static void extractSoundFiles() {
+ try {
+ JarFile jarFile = new JarFile(
+ new File(WerewolfApplication.class.getProtectionDomain().getCodeSource().getLocation().toURI()));
+ File soundFolder = new File("sounds");
+ if (!soundFolder.exists()) {
+ soundFolder.mkdir();
+ }
+ // Logic to clean and extract (simplified from original to avoid full deletion
+ // risk if not intent)
+ // Original code deleted file if it was a file named "soundFolder".
+
+ for (Enumeration em = jarFile.entries(); em.hasMoreElements(); ) {
+ String s = em.nextElement().toString();
+
+ if (s.startsWith(("sounds/")) && s.endsWith(".mp3")) {
+ ZipEntry entry = jarFile.getEntry(s);
+ File outputFile = new File(soundFolder, s.split("/")[s.split("/").length - 1]);
+ // Only write if doesn't exist or explicit overwrite logic?
+ // Legacy code overwrote.
+ InputStream inStream = jarFile.getInputStream(entry);
+ OutputStream out = new FileOutputStream(outputFile);
+ int c;
+ while ((c = inStream.read()) != -1) {
+ out.write(c);
+ }
+ inStream.close();
+ out.close();
+ }
+ }
+ jarFile.close();
+ } catch (Exception e) {
+ log.warn("Failed to extract sound files (ignore if running in IDE): {}", e.getMessage());
+ }
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/WerewolfHelper.java b/src/main/java/dev/robothanzo/werewolf/WerewolfHelper.java
deleted file mode 100644
index 7ce8518..0000000
--- a/src/main/java/dev/robothanzo/werewolf/WerewolfHelper.java
+++ /dev/null
@@ -1,95 +0,0 @@
-package dev.robothanzo.werewolf;
-
-import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
-import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
-import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers;
-import dev.robothanzo.jda.interactions.JDAInteractions;
-import dev.robothanzo.werewolf.database.Database;
-import dev.robothanzo.werewolf.listeners.ButtonListener;
-import dev.robothanzo.werewolf.listeners.GuildJoinListener;
-import dev.robothanzo.werewolf.listeners.MemberJoinListener;
-import dev.robothanzo.werewolf.listeners.MessageListener;
-import lombok.SneakyThrows;
-import lombok.extern.slf4j.Slf4j;
-import net.dv8tion.jda.api.JDA;
-import net.dv8tion.jda.api.JDABuilder;
-import net.dv8tion.jda.api.entities.Activity;
-import net.dv8tion.jda.api.requests.GatewayIntent;
-import net.dv8tion.jda.api.utils.ChunkingFilter;
-import net.dv8tion.jda.api.utils.MemberCachePolicy;
-import net.dv8tion.jda.api.utils.cache.CacheFlag;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.util.EnumSet;
-import java.util.Enumeration;
-import java.util.List;
-import java.util.jar.JarEntry;
-import java.util.jar.JarFile;
-import java.util.zip.ZipEntry;
-
-@Slf4j
-public class WerewolfHelper {
- public static final long AUTHOR = 466769036122783744L;
- public static final List SERVER_CREATORS = List.of(466769036122783744L, 616590798989033502L, 451672040227864587L);
- public static final List ROLES = List.of(
- "狼人", "女巫", "獵人", "預言家", "平民", "狼王", "狼美人", "白狼王", "夢魘", "混血兒", "守衛", "騎士", "白癡",
- "守墓人", "魔術師", "黑市商人", "邱比特", "盜賊", "石像鬼", "狼兄", "狼弟", "複製人", "血月使者", "惡靈騎士", "通靈師", "機械狼", "獵魔人"
- );
- public static JDA jda;
- public static AudioPlayerManager playerManager = new DefaultAudioPlayerManager();
-
- @SneakyThrows
- public static void main(String[] args) {
- extractSoundFiles();
- Database.initDatabase();
- AudioSourceManagers.registerLocalSource(playerManager);
- jda = JDABuilder.createDefault(System.getenv("TOKEN"))
- .enableIntents(GatewayIntent.GUILD_MEMBERS, GatewayIntent.MESSAGE_CONTENT)
- .setChunkingFilter(ChunkingFilter.ALL)
- .setMemberCachePolicy(MemberCachePolicy.ALL)
- .enableCache(EnumSet.allOf(CacheFlag.class))
- .disableCache(CacheFlag.ACTIVITY, CacheFlag.CLIENT_STATUS, CacheFlag.ONLINE_STATUS)
- .addEventListeners(new GuildJoinListener(), new MemberJoinListener(), new MessageListener(), new ButtonListener())
- .build();
- new JDAInteractions("dev.robothanzo.werewolf.commands").registerInteractions(jda).queue();
- jda.awaitReady();
- jda.getPresence().setActivity(Activity.competing("狼人殺 by Hanzo"));
-// new JDAInteractions("dev.robothanzo.werewolf.commands")
-// .registerInteractions(jda.getGuildById(dotenv.get("GUILD"))).queue();
- }
-
- @SneakyThrows
- public static void extractSoundFiles() {
- JarFile jarFile = new JarFile(new File(WerewolfHelper.class.getProtectionDomain().getCodeSource().getLocation()
- .toURI()));
- File soundFolder = new File("sounds");
- if (!soundFolder.exists()) {
- soundFolder.mkdir();
- }
- if (soundFolder.isFile()) {
- soundFolder.delete();
- soundFolder.mkdir();
- }
-
- for (Enumeration em = jarFile.entries(); em.hasMoreElements(); ) {
- String s = em.nextElement().toString();
-
- if (s.startsWith(("sounds/")) && s.endsWith(".mp3")) {
- ZipEntry entry = jarFile.getEntry(s);
- File outputFile = new File(soundFolder, s.split("/")[s.split("/").length - 1]);
- InputStream inStream = jarFile.getInputStream(entry);
- OutputStream out = new FileOutputStream(outputFile);
- int c;
- while ((c = inStream.read()) != -1) {
- out.write(c);
- }
- inStream.close();
- out.close();
- }
- }
- jarFile.close();
- }
-}
diff --git a/src/main/java/dev/robothanzo/werewolf/audio/Audio.java b/src/main/java/dev/robothanzo/werewolf/audio/Audio.java
index 98d035a..5577a1c 100644
--- a/src/main/java/dev/robothanzo/werewolf/audio/Audio.java
+++ b/src/main/java/dev/robothanzo/werewolf/audio/Audio.java
@@ -5,7 +5,7 @@
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
-import dev.robothanzo.werewolf.WerewolfHelper;
+import dev.robothanzo.werewolf.WerewolfApplication;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import net.dv8tion.jda.api.managers.AudioManager;
@@ -18,9 +18,9 @@ public static void play(Resource resource, AudioChannel channel) {
try {
AudioManager audioManager = channel.getGuild().getAudioManager();
audioManager.openAudioConnection(channel);
- AudioPlayer player = WerewolfHelper.playerManager.createPlayer();
+ AudioPlayer player = WerewolfApplication.playerManager.createPlayer();
audioManager.setSendingHandler(new AudioPlayerSendHandler(player));
- WerewolfHelper.playerManager.loadItem("sounds/" + resource + ".mp3", new AudioLoadResultHandler() {
+ WerewolfApplication.playerManager.loadItem("sounds/" + resource + ".mp3", new AudioLoadResultHandler() {
@Override
public void trackLoaded(AudioTrack track) {
player.startTrack(track, false);
@@ -45,7 +45,8 @@ public void loadFailed(FriendlyException exception) {
}
public enum Resource {
- EXPEL_POLL, POLICE_ENROLL, POLICE_POLL, TIMER_ENDED, ENROLL_10S_REMAINING, POLL_10S_REMAINING, TIMER_30S_REMAINING;
+ EXPEL_POLL, POLICE_ENROLL, POLICE_POLL, TIMER_ENDED, ENROLL_10S_REMAINING, POLL_10S_REMAINING,
+ TIMER_30S_REMAINING;
@Override
public String toString() {
diff --git a/src/main/java/dev/robothanzo/werewolf/commands/Player.java b/src/main/java/dev/robothanzo/werewolf/commands/Player.java
index 091b6e9..9b608eb 100644
--- a/src/main/java/dev/robothanzo/werewolf/commands/Player.java
+++ b/src/main/java/dev/robothanzo/werewolf/commands/Player.java
@@ -3,7 +3,7 @@
import dev.robothanzo.jda.interactions.annotations.slash.Command;
import dev.robothanzo.jda.interactions.annotations.slash.Subcommand;
import dev.robothanzo.jda.interactions.annotations.slash.options.Option;
-import dev.robothanzo.werewolf.WerewolfHelper;
+import dev.robothanzo.werewolf.WerewolfApplication;
import dev.robothanzo.werewolf.database.documents.Session;
import dev.robothanzo.werewolf.utils.CmdUtils;
import dev.robothanzo.werewolf.utils.MsgUtils;
@@ -11,14 +11,14 @@
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.EmbedBuilder;
+import net.dv8tion.jda.api.components.actionrow.ActionRow;
+import net.dv8tion.jda.api.components.buttons.Button;
+import net.dv8tion.jda.api.components.selections.EntitySelectMenu;
import net.dv8tion.jda.api.entities.*;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.EntitySelectInteractionEvent;
-import net.dv8tion.jda.api.components.actionrow.ActionRow;
-import net.dv8tion.jda.api.components.buttons.Button;
-import net.dv8tion.jda.api.components.selections.EntitySelectMenu;
import org.jetbrains.annotations.Nullable;
import java.util.*;
@@ -31,7 +31,8 @@
public class Player {
public static Map transferPoliceSessions = new HashMap<>(); // key is guild ID
- public static void transferPolice(Session session, Guild guild, Session.Player player, @Nullable Runnable callback) {
+ public static void transferPolice(Session session, Guild guild, Session.Player player,
+ @Nullable Runnable callback) {
if (player.isPolice()) {
assert player.getUserId() != null;
transferPoliceSessions.put(guild.getIdLong(), TransferPoliceSession.builder()
@@ -39,21 +40,26 @@ public static void transferPolice(Session session, Guild guild, Session.Player p
.senderId(player.getUserId())
.callback(callback)
.build());
- EntitySelectMenu.Builder selectMenu = EntitySelectMenu.create("selectNewPolice", EntitySelectMenu.SelectTarget.USER)
+ EntitySelectMenu.Builder selectMenu = EntitySelectMenu
+ .create("selectNewPolice", EntitySelectMenu.SelectTarget.USER)
.setMinValues(1)
.setMaxValues(1);
- for (Session.Player p : session.getPlayers().values()) {
+ for (Session.Player p : session.fetchAlivePlayers().values()) {
assert p.getUserId() != null;
- if (Objects.equals(p.getUserId(), player.getUserId())) continue;
- User user = WerewolfHelper.jda.getUserById(p.getUserId());
+ if (Objects.equals(p.getUserId(), player.getUserId()))
+ continue;
+ User user = WerewolfApplication.jda.getUserById(p.getUserId());
assert user != null;
transferPoliceSessions.get(guild.getIdLong()).getPossibleRecipientIds().add(p.getUserId());
}
- Message message = Objects.requireNonNull(guild.getTextChannelById(session.getCourtTextChannelId())).sendMessageEmbeds(
+ Message message = Objects
+ .requireNonNull(guild.getTextChannelById(session.getCourtTextChannelId())).sendMessageEmbeds(
new EmbedBuilder()
.setTitle("移交警徽").setColor(MsgUtils.getRandomColor())
.setDescription("請選擇要移交警徽的對象,若要撕掉警徽,請按下撕毀按鈕\n請在30秒內做出選擇,否則警徽將被自動撕毀").build())
- .setComponents(ActionRow.of(selectMenu.build()), ActionRow.of(Button.success("confirmNewPolice", "移交"), Button.danger("destroyPolice", "撕毀")))
+ .setComponents(ActionRow.of(selectMenu.build()),
+ ActionRow.of(Button.success("confirmNewPolice", "移交"),
+ Button.danger("destroyPolice", "撕毀")))
.complete();
CmdUtils.schedule(() -> {
if (transferPoliceSessions.remove(guild.getIdLong()) != null) {
@@ -61,19 +67,73 @@ public static void transferPolice(Session session, Guild guild, Session.Player p
}
}, 30000);
}
- if (callback != null) callback.run();
+ if (callback != null)
+ callback.run();
}
- public static boolean playerDied(Session session, Member user, boolean lastWords, boolean isExpelled) { // returns whether the action succeeded
- Guild guild = Objects.requireNonNull(WerewolfHelper.jda.getGuildById(session.getGuildId()));
+ public static boolean playerDied(Session session, Member user, boolean lastWords, boolean isExpelled) { // returns
+ // whether
+ // the
+ // action
+ // succeeded
+ Guild guild = Objects.requireNonNull(WerewolfApplication.jda.getGuildById(session.getGuildId()));
Role spectatorRole = Objects.requireNonNull(guild.getRoleById(session.getSpectatorRoleId()));
for (Map.Entry player : new LinkedList<>(session.getPlayers().entrySet())) {
if (Objects.equals(user.getIdLong(), player.getValue().getUserId())) {
- assert player.getValue().getRoles() != null;
- if (player.getValue().getRoles().isEmpty()) {
+
+ // Check if already fully dead
+ if (!player.getValue().isAlive()) {
return false;
}
- Session.Result result = session.hasEnded(player.getValue().getRoles().getFirst());
+
+ assert player.getValue().getRoles() != null;
+
+ // Soft kill logic: Find first alive role and kill it
+ List roles = player.getValue().getRoles();
+ List deadRoles = player.getValue().getDeadRoles();
+ if (deadRoles == null) {
+ deadRoles = new ArrayList<>();
+ player.getValue().setDeadRoles(deadRoles);
+ }
+
+ String killedRole = null;
+ for (String role : roles) {
+ long totalCount = roles.stream().filter(r -> r.equals(role)).count();
+ long deadCount = deadRoles.stream().filter(r -> r.equals(role)).count();
+
+ if (deadCount < totalCount) {
+ killedRole = role;
+ deadRoles.add(role);
+ break;
+ }
+ }
+
+ // Persist the dead role update immediately to ensure consistency
+ Session.fetchCollection().updateOne(eq("guildId", session.getGuildId()),
+ set("players", session.getPlayers()));
+
+ // Log the death
+ if (killedRole != null) {
+ Map metadata = new HashMap<>();
+ metadata.put("playerId", player.getValue().getId());
+ metadata.put("playerName", player.getValue().getNickname());
+ metadata.put("killedRole", killedRole);
+ metadata.put("isExpelled", isExpelled);
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.PLAYER_DIED,
+ player.getValue().getNickname() + " 的 " + killedRole + " 身份已死亡",
+ metadata);
+
+ // Send message to court channel
+ TextChannel courtChannel = guild.getTextChannelById(session.getCourtTextChannelId());
+ if (courtChannel != null) {
+ courtChannel
+ .sendMessage("**:skull: " + user.getAsMention() + " 已死亡**")
+ .queue();
+ }
+ }
+
+ // Check game ended logic with the newly killed role
+ Session.Result result = session.hasEnded(killedRole);
if (result != Session.Result.NOT_ENDED) {
TextChannel channel = guild.getTextChannelById(session.getSpectatorTextChannelId());
String judgePing = "<@&" + session.getJudgeRoleId() + "> ";
@@ -86,37 +146,70 @@ public static boolean playerDied(Session session, Member user, boolean lastWords
lastWords = false;
}
}
- if (player.getValue().getRoles().size() == 2) {
- player.getValue().getRoles().removeFirst();
+
+ // Check if player is still alive (has remaining roles)
+ if (player.getValue().isAlive()) {
+ // Calculate remaining roles for the message
+ List remainingRoles = new ArrayList<>(player.getValue().getRoles());
+ if (player.getValue().getDeadRoles() != null) {
+ for (String deadRole : player.getValue().getDeadRoles()) {
+ remainingRoles.remove(deadRole);
+ }
+ }
+ String remainingRoleName = remainingRoles.isEmpty() ? "未知" : remainingRoles.getFirst();
+
+ // Not fully dead, passed out one role
Objects.requireNonNull(guild.getTextChannelById(player.getValue().getChannelId()))
- .sendMessage("因為你死了,所以你的角色變成了 " + player.getValue().getRoles().getFirst()).queue();
- Session.fetchCollection().updateOne(eq("guildId", session.getGuildId()), set("players", session.getPlayers()));
+ .sendMessage("因為你死了,所以你的角色變成了 " + remainingRoleName).queue();
+ Session.fetchCollection().updateOne(eq("guildId", session.getGuildId()),
+ set("players", session.getPlayers()));
if (lastWords) {
- Speech.lastWordsSpeech(guild, Objects.requireNonNull(guild.getTextChannelById(session.getCourtTextChannelId())), player.getValue(), null);
+ WerewolfApplication.speechService.startLastWordsSpeech(guild,
+ session.getCourtTextChannelId(),
+ player.getValue(), null);
}
return true;
}
- if (player.getValue().getRoles().size() == 1) {
- Runnable die = () -> transferPolice(session, guild, player.getValue(), () -> {
- var newSession = CmdUtils.getSession(guild); // We need to update the session as it may have been tampered with by transferPolice
- if (newSession == null) return;
- user.modifyNickname("[死人] " + user.getEffectiveName()).queue();
- if (player.getValue().isIdiot() && isExpelled) {
- player.getValue().getRoles().removeFirst();
- newSession.getPlayers().put(player.getKey(), player.getValue());
- Session.fetchCollection().updateOne(eq("guildId", newSession.getGuildId()), set("players", newSession.getPlayers()));
- Objects.requireNonNull(guild.getTextChannelById(newSession.getCourtTextChannelId())).sendMessage(user.getAsMention() + " 是白癡,所以他會待在場上並繼續發言").queue();
- } else {
- guild.modifyMemberRoles(user, spectatorRole).queue();
- newSession.getPlayers().remove(player.getKey());
- Session.fetchCollection().updateOne(eq("guildId", newSession.getGuildId()), set("players", newSession.getPlayers()));
- }
- });
- if (lastWords) {
- Speech.lastWordsSpeech(guild, Objects.requireNonNull(guild.getTextChannelById(session.getCourtTextChannelId())), player.getValue(), die);
+
+ // Fully dead logic
+ String finalKilledRole = killedRole;
+ Runnable die = () -> transferPolice(session, guild, player.getValue(), () -> {
+ var newSession = CmdUtils.getSession(guild); // We need to update the session as it may have been
+ // tampered with by transferPolice
+ if (newSession == null)
+ return;
+
+ // We need to fetch the updated player object from the new session to make sure
+ // we have latest police status etc.
+ // But assume player state is managed by references or we need to re-fetch.
+ // For safety, let's use the object we have but ensure police status is false if
+ // transferred.
+ // Actually transferPolice callback runs AFTER transfer.
+
+ if (player.getValue().isIdiot() && isExpelled) {
+ player.getValue().getDeadRoles().remove(finalKilledRole);
+
+ newSession.getPlayers().put(player.getKey(), player.getValue());
+ Session.fetchCollection().updateOne(eq("guildId", newSession.getGuildId()),
+ set("players", newSession.getPlayers()));
+ WerewolfApplication.gameSessionService.broadcastSessionUpdate(newSession);
+ Objects.requireNonNull(guild.getTextChannelById(newSession.getCourtTextChannelId()))
+ .sendMessage(user.getAsMention() + " 是白癡,所以他會待在場上並繼續發言").queue();
} else {
- die.run();
+ guild.modifyMemberRoles(user, spectatorRole).queue();
+ Session.fetchCollection().updateOne(eq("guildId", newSession.getGuildId()),
+ set("players", newSession.getPlayers()));
+ player.getValue().updateNickname(user);
+ WerewolfApplication.gameSessionService.broadcastSessionUpdate(newSession);
}
+ });
+
+ if (lastWords) {
+ WerewolfApplication.speechService.startLastWordsSpeech(guild,
+ session.getCourtTextChannelId(),
+ player.getValue(), die);
+ } else {
+ die.run();
}
return true;
}
@@ -126,16 +219,85 @@ public static boolean playerDied(Session session, Member user, boolean lastWords
return true;
}
+ public static boolean playerRevived(Session session, Member user, String roleToRevive) {
+ Guild guild = Objects.requireNonNull(WerewolfApplication.jda.getGuildById(session.getGuildId()));
+ Role spectatorRole = Objects.requireNonNull(guild.getRoleById(session.getSpectatorRoleId()));
+
+ for (Map.Entry player : session.getPlayers().entrySet()) {
+ if (Objects.equals(user.getIdLong(), player.getValue().getUserId())) {
+ List deadRoles = player.getValue().getDeadRoles();
+ if (deadRoles == null || !deadRoles.contains(roleToRevive)) {
+ return false; // Role is not dead or invalid
+ }
+
+ // Check if player WAS fully dead before this revival
+ boolean wasFullyDead = !player.getValue().isAlive();
+
+ // Revive the role
+ deadRoles.remove(roleToRevive);
+
+ // Update session immediately
+ Session.fetchCollection().updateOne(eq("guildId", session.getGuildId()),
+ set("players", session.getPlayers()));
+
+ // Log the revival
+ Map metadata = new HashMap<>();
+ metadata.put("playerId", player.getValue().getId());
+ metadata.put("playerName", player.getValue().getNickname());
+ metadata.put("revivedRole", roleToRevive);
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.PLAYER_REVIVED,
+ player.getValue().getNickname() + " 的 " + roleToRevive + " 身份已復活",
+ metadata);
+
+ // Handle transition from Dead -> Alive
+ if (wasFullyDead) {
+ guild.removeRoleFromMember(user, spectatorRole).queue();
+ }
+ player.getValue().updateNickname(user);
+
+ // Calculate remaining roles for message
+ List remainingRoles = new ArrayList<>(player.getValue().getRoles());
+ for (String deadRole : deadRoles) {
+ remainingRoles.remove(deadRole);
+ }
+ String currentRoleName = remainingRoles.isEmpty() ? "未知" : remainingRoles.getFirst();
+
+ // Restore Role Logic using Session values
+ long roleId = player.getValue().getRoleId();
+ if (roleId != 0) {
+ Role role = guild.getRoleById(roleId);
+ if (role != null) {
+ guild.addRoleToMember(user, role).queue();
+ }
+ }
+
+ // Send notification
+ TextChannel channel = guild.getTextChannelById(player.getValue().getChannelId());
+ if (channel != null) {
+ channel.sendMessage("因為你復活了,所以你的角色變成了 " + currentRoleName).queue();
+ }
+
+ // Broadcast updates after all changes including nickname
+ WerewolfApplication.gameSessionService.broadcastSessionUpdate(session);
+ return true;
+ }
+ }
+ return false;
+ }
+
public static void selectNewPolice(EntitySelectInteractionEvent event) {
if (transferPoliceSessions.containsKey(Objects.requireNonNull(event.getGuild()).getIdLong())) {
Member target = event.getMentions().getMembers().getFirst();
- TransferPoliceSession session = transferPoliceSessions.get(Objects.requireNonNull(event.getGuild()).getIdLong());
+ TransferPoliceSession session = transferPoliceSessions
+ .get(Objects.requireNonNull(event.getGuild()).getIdLong());
if (!session.getPossibleRecipientIds().contains(target.getIdLong())) {
event.reply(":x: 你不能移交警徽給這個人").setEphemeral(true).queue();
} else {
if (session.getSenderId() == event.getUser().getIdLong()) {
- Session guildSession = Session.fetchCollection().find(eq("guildId", event.getGuild().getIdLong())).first();
- if (guildSession == null) return;
+ Session guildSession = Session.fetchCollection().find(eq("guildId", event.getGuild().getIdLong()))
+ .first();
+ if (guildSession == null)
+ return;
for (var player : guildSession.getPlayers().values()) {
if (Objects.requireNonNull(player.getUserId()) == target.getIdLong()) {
session.setRecipientId(player.getId());
@@ -153,25 +315,56 @@ public static void selectNewPolice(EntitySelectInteractionEvent event) {
@dev.robothanzo.jda.interactions.annotations.Button
public static void confirmNewPolice(ButtonInteractionEvent event) {
if (transferPoliceSessions.containsKey(Objects.requireNonNull(event.getGuild()).getIdLong())) {
- TransferPoliceSession session = transferPoliceSessions.get(Objects.requireNonNull(event.getGuild()).getIdLong());
+ TransferPoliceSession session = transferPoliceSessions
+ .get(Objects.requireNonNull(event.getGuild()).getIdLong());
if (session.getSenderId() == event.getUser().getIdLong()) {
if (session.getRecipientId() != null) {
- Session.fetchCollection().updateOne(eq("guildId", event.getGuild().getIdLong()), set("players." + session.getRecipientId() + ".police", true));
- log.info("Transferred police to " + session.getRecipientId() + " in guild " + event.getGuild().getIdLong());
+ Session.fetchCollection().updateOne(eq("guildId", event.getGuild().getIdLong()),
+ set("players." + session.getRecipientId() + ".police", true));
+ log.info("Transferred police to " + session.getRecipientId() + " in guild "
+ + event.getGuild().getIdLong());
transferPoliceSessions.remove(event.getGuild().getIdLong());
- Long recipientDiscordId = Objects.requireNonNull(CmdUtils.getSession(event)).getPlayers().get(session.getRecipientId().toString()).getUserId();
+
+ // Update Recipient Nickname
+ Session.Player recipientPlayer = Objects.requireNonNull(CmdUtils.getSession(event)).getPlayers()
+ .get(session.getRecipientId().toString());
+ recipientPlayer.setPolice(true);
+ Long recipientDiscordId = recipientPlayer.getUserId();
if (recipientDiscordId != null) {
Member recipient = event.getGuild().getMemberById(recipientDiscordId);
if (recipient != null) {
- recipient.modifyNickname(recipient.getEffectiveName() + " [警長]").queue();
+ recipientPlayer.updateNickname(recipient);
event.reply(":white_check_mark: 警徽已移交給 " + recipient.getAsMention()).queue();
}
}
+
+ // Update Sender Nickname
Member sender = event.getGuild().getMemberById(session.getSenderId());
+ Session.fetchCollection()
+ .updateOne(eq("guildId", event.getGuild().getIdLong()),
+ set("players."
+ + Objects.requireNonNull(CmdUtils.getSession(event)).getPlayers().entrySet()
+ .stream()
+ .filter(e -> Objects.equals(e.getValue().getUserId(),
+ session.getSenderId()))
+ .findFirst().get().getKey()
+ + ".police", false));
+
if (sender != null) {
- sender.modifyNickname(sender.getEffectiveName().replace(" [警長]", "")).queue();
+ // We need the player object for sender to correctly regenerate name (e.g. if
+ // they are dead?)
+ // Usually transfer logic happens when dead, but could be alive transfer.
+ var senderEntry = Objects.requireNonNull(CmdUtils.getSession(event)).getPlayers().entrySet()
+ .stream().filter(e -> Objects.equals(e.getValue().getUserId(), session.getSenderId()))
+ .findFirst();
+ if (senderEntry.isPresent()) {
+ Session.Player senderPlayer = senderEntry.get().getValue();
+ senderPlayer.setPolice(false);
+ senderPlayer.updateNickname(sender);
+ }
}
- if (session.getCallback() != null) session.getCallback().run();
+ if (session.getCallback() != null)
+ session.getCallback().run();
} else {
event.reply(":x: 請先選擇要移交警徽的對象").setEphemeral(true).queue();
}
@@ -184,11 +377,13 @@ public static void confirmNewPolice(ButtonInteractionEvent event) {
@dev.robothanzo.jda.interactions.annotations.Button
public static void destroyPolice(ButtonInteractionEvent event) {
if (transferPoliceSessions.containsKey(Objects.requireNonNull(event.getGuild()).getIdLong())) {
- TransferPoliceSession session = transferPoliceSessions.get(Objects.requireNonNull(event.getGuild()).getIdLong());
+ TransferPoliceSession session = transferPoliceSessions
+ .get(Objects.requireNonNull(event.getGuild()).getIdLong());
if (session.getSenderId() == event.getUser().getIdLong()) {
transferPoliceSessions.remove(event.getGuild().getIdLong());
event.reply(":white_check_mark: 警徽已撕毀").setEphemeral(false).queue();
- if (session.getCallback() != null) session.getCallback().run();
+ if (session.getCallback() != null)
+ session.getCallback().run();
} else {
event.reply(":x: 你不是原本的警長").setEphemeral(true).queue();
}
@@ -197,44 +392,53 @@ public static void destroyPolice(ButtonInteractionEvent event) {
@dev.robothanzo.jda.interactions.annotations.Button
public void changeRoleOrder(ButtonInteractionEvent event) {
+ if (event.getGuild() == null)
+ return;
+ event.deferReply().queue();
Session session = CmdUtils.getSession(event);
- if (session == null) return;
+ if (session == null)
+ return;
+
for (Session.Player player : session.getPlayers().values()) {
if (Objects.equals(event.getUser().getIdLong(), player.getUserId())) {
- assert player.getRoles() != null;
- if (player.isRolePositionLocked()) {
- event.getHook().editOriginal(":x: 你的身分順序已被鎖定").queue();
- return;
+ try {
+ WerewolfApplication.playerService.switchRoleOrder(event.getGuild().getIdLong(),
+ String.valueOf(player.getId()));
+ event.getHook().editOriginal(":white_check_mark: 交換成功").queue();
+ } catch (Exception e) {
+ event.getHook().editOriginal(":x: " + e.getMessage()).queue();
}
- Collections.reverse(player.getRoles());
- event.reply(":white_check_mark: 你目前的順序: " + String.join("、", player.getRoles())).queue();
- Session.fetchCollection().updateOne(eq("guildId", Objects.requireNonNull(event.getGuild()).getIdLong()),
- set("players", session.getPlayers()));
return;
}
}
- event.reply(":x:").queue();
+ event.getHook().editOriginal(":x: 你不是玩家").queue();
}
@Subcommand(description = "升官為法官")
public void judge(SlashCommandInteractionEvent event, @Option(value = "user") User user) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
+ if (!CmdUtils.isAdmin(event))
+ return;
Session session = CmdUtils.getSession(event);
- if (session == null) return;
+ if (session == null)
+ return;
Objects.requireNonNull(event.getGuild()).addRoleToMember(
- Objects.requireNonNull(event.getGuild().getMemberById(user.getId())), Objects.requireNonNull(event.getGuild().getRoleById(session.getJudgeRoleId()))).queue();
+ Objects.requireNonNull(event.getGuild().getMemberById(user.getId())),
+ Objects.requireNonNull(event.getGuild().getRoleById(session.getJudgeRoleId()))).queue();
event.getHook().editOriginal(":white_check_mark:").queue();
}
@Subcommand(description = "貶官為庶民")
public void demote(SlashCommandInteractionEvent event, @Option(value = "user") User user) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
+ if (!CmdUtils.isAdmin(event))
+ return;
Session session = CmdUtils.getSession(event);
- if (session == null) return;
+ if (session == null)
+ return;
Objects.requireNonNull(event.getGuild()).removeRoleFromMember(
- Objects.requireNonNull(event.getGuild().getMemberById(user.getId())), Objects.requireNonNull(event.getGuild().getRoleById(session.getJudgeRoleId()))).queue();
+ Objects.requireNonNull(event.getGuild().getMemberById(user.getId())),
+ Objects.requireNonNull(event.getGuild().getRoleById(session.getJudgeRoleId()))).queue();
event.getHook().editOriginal(":white_check_mark:").queue();
}
@@ -242,10 +446,13 @@ public void demote(SlashCommandInteractionEvent event, @Option(value = "user") U
public void died(SlashCommandInteractionEvent event, @Option(value = "user", description = "死掉的使用者") User user,
@Option(value = "last_words", description = "是否讓他講遺言 (預設為否) (若為雙身分,只會在兩張牌都死掉的時候啟動)", optional = true) Boolean lastWords) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
+ if (!CmdUtils.isAdmin(event))
+ return;
Session session = CmdUtils.getSession(event);
- if (session == null) return;
- if (lastWords == null) lastWords = false;
+ if (session == null)
+ return;
+ if (lastWords == null)
+ lastWords = false;
Member member = Objects.requireNonNull(Objects.requireNonNull(event.getGuild()).getMemberById(user.getId()));
if (playerDied(session, member, lastWords, false)) {
@@ -258,151 +465,60 @@ public void died(SlashCommandInteractionEvent event, @Option(value = "user", des
@Subcommand(description = "指派玩家編號並傳送身分")
public void assign(SlashCommandInteractionEvent event) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
- Session session = CmdUtils.getSession(event);
- if (session == null) return;
- List pending = new LinkedList<>();
- for (Member member : Objects.requireNonNull(event.getGuild()).getMembers()) {
- if ((!member.getUser().isBot()) &&
- (!member.getRoles().contains(event.getGuild().getRoleById(session.getJudgeRoleId()))) &&
- (!member.getRoles().contains(event.getGuild().getRoleById(session.getSpectatorRoleId())))) {
- pending.add(member);
- }
- }
- Collections.shuffle(pending, new Random());
- if (pending.size() != session.getPlayers().size()) {
- event.getHook().editOriginal(
- ":x: 玩家數量不符合設定之,請確認是否已給予旁觀者應有之身分(使用`/player died`),是則請使用`/server set players`來更改總玩家人數").queue();
+ if (!CmdUtils.isAdmin(event))
return;
- }
- if (pending.size() != (session.getRoles().size() / (session.isDoubleIdentities() ? 2 : 1))) {
- event.getHook().editOriginal(
- ":x: 玩家身分數量不符合身分數量,請確認是否正確啟用/停用雙身分模式(使用`/server set double_identities`),並檢查是否正確設定身分(使用`/server roles list`檢查)").queue();
+ Session session = CmdUtils.getSession(event);
+ if (session == null)
return;
+
+ try {
+ WerewolfApplication.roleService.assignRoles(event.getGuild().getIdLong(),
+ msg -> log.info("[Assign] " + msg),
+ p -> {
+ });
+ event.getHook().editOriginal(":white_check_mark: 身分分配完成!").queue();
+ } catch (Exception e) {
+ event.getHook().editOriginal(":x: " + e.getMessage()).queue();
}
- List roles = session.getRoles();
- Collections.shuffle(roles);
- int gaveJinBaoBao = 0;
- for (Session.Player player : session.getPlayers().values()) {
- event.getGuild().addRoleToMember(pending.get(player.getId() - 1),
- Objects.requireNonNull(event.getGuild().getRoleById(player.getRoleId()))).queue();
- event.getGuild().modifyNickname(pending.get(player.getId() - 1), "玩家" + player.getId()).queue();
- player.setUserId(pending.get(player.getId() - 1).getIdLong());
- List rs = new LinkedList<>();
- // at least one jin bao bao in a double identities game
- boolean isJinBaoBao = false;
- rs.add(roles.removeFirst());
- if (rs.getFirst().equals("白癡")) {
- player.setIdiot(true);
- }
- if (rs.getFirst().equals("平民") && gaveJinBaoBao == 0 && session.isDoubleIdentities()) {
- rs = List.of("平民", "平民");
- roles.remove("平民");
- gaveJinBaoBao++;
- isJinBaoBao = true;
- } else if (session.isDoubleIdentities()) {
- boolean shouldRemove = true;
- rs.add(roles.getFirst());
- if (rs.contains("複製人")) {
- player.setDuplicated(true);
- if (rs.getFirst().equals("複製人")) {
- rs.set(0, rs.get(1));
- } else {
- rs.set(1, rs.getFirst());
- }
- }
- if (rs.getFirst().equals("平民") && rs.get(1).equals("平民")) {
- if (gaveJinBaoBao >= 2) {
- for (var r : new ArrayList<>(roles)) {
- if (!r.equals("平民")) {
- rs.set(1, r);
- roles.remove(r);
- shouldRemove = false;
- break;
- }
- }
- }
- if (rs.getFirst().equals("平民") && rs.get(1).equals("平民")) { // just in case they still got a jin bao bao
- isJinBaoBao = true;
- gaveJinBaoBao++;
- }
- }
- if (rs.getFirst().contains("狼")) {
- Collections.reverse(rs);
- }
- if (shouldRemove)
- roles.removeFirst();
- }
- player.setJinBaoBao(isJinBaoBao && session.isDoubleIdentities());
- player.setRoles(rs);
- var action = Objects.requireNonNull(event.getGuild().getTextChannelById(player.getChannelId())).sendMessageEmbeds(new EmbedBuilder()
- .setTitle("你抽到的身分是 (若為狼人或金寶寶請使用自己的頻道來和隊友討論及確認身分)")
- .setDescription(String.join("、", rs) + (player.isJinBaoBao() ? " (金寶寶)" : "") +
- (player.isDuplicated() ? " (複製人)" : ""))
- .setColor(MsgUtils.getRandomColor()).build());
- if (session.isDoubleIdentities()) {
- action.setComponents(ActionRow.of(Button.primary("changeRoleOrder", "更換身分順序 (請在收到身分後全分聽完再使用,逾時不候)")));
- CmdUtils.schedule(() -> {
- Session.fetchCollection().updateOne(eq("guildId", event.getGuild().getIdLong()),
- set("players." + player.getId() + ".rolePositionLocked", true));
- Objects.requireNonNull(event.getGuild().getTextChannelById(player.getChannelId())).sendMessage("身分順序已鎖定").queue();
- }, 120000);
- }
- action.queue();
- Session.fetchCollection().updateOne(eq("guildId", event.getGuild().getIdLong()),
- set("players", session.getPlayers()));
- }
- Session.fetchCollection().updateOne(eq("guildId", event.getGuild().getIdLong()),
- set("hasAssignedRoles", true));
- event.getHook().editOriginal(":white_check_mark:").queue();
}
@Subcommand(description = "列出每個玩家的身分資訊")
public void roles(SlashCommandInteractionEvent event) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
+ if (!CmdUtils.isAdmin(event))
+ return;
Session session = CmdUtils.getSession(event);
- if (session == null) return;
+ if (session == null)
+ return;
EmbedBuilder embedBuilder = new EmbedBuilder()
.setTitle("身分列表")
.setColor(MsgUtils.getRandomColor());
for (String p : session.getPlayers().keySet().stream().sorted(MsgUtils.getAlphaNumComparator()).toList()) {
Session.Player player = session.getPlayers().get(p);
assert player.getRoles() != null;
- embedBuilder.addField("玩家" + p,
+ embedBuilder.addField(player.getNickname(),
String.join("、", player.getRoles()) + (player.isPolice() ? " (警長)" : "") +
- (player.isJinBaoBao() ? " (金寶寶)" : player.isDuplicated() ? " (複製人)" : ""), true);
+ (player.isJinBaoBao() ? " (金寶寶)" : player.isDuplicated() ? " (複製人)" : ""),
+ true);
}
event.getHook().editOriginalEmbeds(embedBuilder.build()).queue();
}
@Subcommand(description = "強制某人成為警長 (將會清除舊的警長)")
- public void force_police(SlashCommandInteractionEvent
- event, @Option(value = "user", description = "要強制成為警長的玩家") User user) {
+ public void force_police(SlashCommandInteractionEvent event,
+ @Option(value = "user", description = "要強制成為警長的玩家") User user) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
- if (event.getGuild() == null) return;
- Session session = CmdUtils.getSession(event);
- if (session == null) return;
- for (Session.Player player : session.getPlayers().values()) {
- if (player.isPolice() && !Objects.equals(player.getUserId(), user.getIdLong())) {
- player.setPolice(false);
- Session.fetchCollection().updateOne(eq("guildId", session.getGuildId()), set("players", session.getPlayers()));
- Member member = event.getGuild().getMemberById(Objects.requireNonNull(player.getUserId()));
- if (member != null) {
- member.modifyNickname(member.getEffectiveName().replace(" [警長]", "")).queue();
- }
- }
- if (Objects.equals(player.getUserId(), user.getIdLong())) {
- player.setPolice(true);
- Session.fetchCollection().updateOne(eq("guildId", session.getGuildId()), set("players", session.getPlayers()));
- Member member = event.getGuild().getMemberById(Objects.requireNonNull(player.getUserId()));
- if (member != null) {
- member.modifyNickname(member.getEffectiveName() + " [警長]").queue();
- }
- }
+ if (!CmdUtils.isAdmin(event))
+ return;
+ if (event.getGuild() == null)
+ return;
+
+ try {
+ WerewolfApplication.gameActionService.setPolice(event.getGuild().getIdLong(), user.getIdLong());
+ event.getHook().editOriginal(":white_check_mark:").queue();
+ } catch (Exception e) {
+ event.getHook().editOriginal(":x: " + e.getMessage()).queue();
}
- event.getHook().editOriginal(":white_check_mark:").queue();
}
@Data
diff --git a/src/main/java/dev/robothanzo/werewolf/commands/Poll.java b/src/main/java/dev/robothanzo/werewolf/commands/Poll.java
index d1e512e..fc6b5b4 100644
--- a/src/main/java/dev/robothanzo/werewolf/commands/Poll.java
+++ b/src/main/java/dev/robothanzo/werewolf/commands/Poll.java
@@ -2,90 +2,102 @@
import dev.robothanzo.jda.interactions.annotations.slash.Command;
import dev.robothanzo.jda.interactions.annotations.slash.Subcommand;
-import dev.robothanzo.werewolf.WerewolfHelper;
+import dev.robothanzo.werewolf.WerewolfApplication;
import dev.robothanzo.werewolf.audio.Audio;
import dev.robothanzo.werewolf.database.documents.Session;
+import dev.robothanzo.werewolf.model.Candidate;
import dev.robothanzo.werewolf.utils.CmdUtils;
import dev.robothanzo.werewolf.utils.MsgUtils;
-import lombok.Builder;
-import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.EmbedBuilder;
+import net.dv8tion.jda.api.components.actionrow.ActionRow;
+import net.dv8tion.jda.api.components.buttons.Button;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
-import net.dv8tion.jda.api.components.actionrow.ActionRow;
-import net.dv8tion.jda.api.components.buttons.Button;
-import org.jetbrains.annotations.Nullable;
-import java.util.*;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
-import static com.mongodb.client.model.Filters.eq;
-import static com.mongodb.client.model.Updates.set;
-
@Command
+@Slf4j
public class Poll {
- public static Map> expelCandidates = new ConcurrentHashMap<>(); // key is guild id // second key is candidate id
+ public static Map> expelCandidates = new ConcurrentHashMap<>(); // key is guild id //
+ // second key is
+ // candidate id
- public static void handleExpelPK(Session session, GuildMessageChannel channel, Message message, List winners) {
- message.reply("平票,請PK").queue();
+ public static void handleExpelPK(Session session, GuildMessageChannel channel, Message message,
+ List winners) {
+ if (message != null)
+ message.reply("平票,請PK").queue();
Map newCandidates = new ConcurrentHashMap<>();
for (Candidate winner : winners) {
- winner.electors.clear();
+ winner.getElectors().clear();
winner.setExpelPK(true);
newCandidates.put(winner.getPlayer().getId(), winner);
}
expelCandidates.put(channel.getGuild().getIdLong(), newCandidates);
- Speech.pollSpeech(channel.getGuild(), message, newCandidates.values().stream().map(Candidate::getPlayer).toList(),
+ WerewolfApplication.speechService.startSpeechPoll(channel.getGuild(), message,
+ newCandidates.values().stream().map(Candidate::getPlayer).toList(),
() -> startExpelPoll(session, channel, false));
}
public static void startExpelPoll(Session session, GuildMessageChannel channel, boolean allowPK) {
Audio.play(Audio.Resource.EXPEL_POLL, channel.getGuild().getVoiceChannelById(session.getCourtVoiceChannelId()));
- EmbedBuilder embedBuilder = new EmbedBuilder().setTitle("驅逐投票").setDescription("30秒後立刻計票,請加快手速!\n若要改票可直接按下要改成的對象\n若要改為棄票需按下原本投給的使用者").setColor(MsgUtils.getRandomColor());
+ EmbedBuilder embedBuilder = new EmbedBuilder().setTitle("驅逐投票")
+ .setDescription("30秒後立刻計票,請加快手速!\n若要改票可直接按下要改成的對象\n若要改為棄票需按下原本投給的使用者")
+ .setColor(MsgUtils.getRandomColor());
List buttons = new LinkedList<>();
for (Candidate player : expelCandidates.get(channel.getGuild().getIdLong()).values()
.stream().sorted(Candidate.getComparator()).toList()) {
- assert player.getPlayer().getRoles() != null;
- if (player.getPlayer().getRoles().isEmpty()) continue;
- if (player.isQuit()) continue;
- assert player.getPlayer().getUserId() != null;
Member user = channel.getGuild().getMemberById(player.getPlayer().getUserId());
- assert user != null;
- buttons.add(Button.primary("voteExpel" + player.getPlayer().getId(),
- "玩家" + player.getPlayer().getId() + " (" + user.getUser().getName() + ")"));
+ if (user != null) {
+ buttons.add(Button.danger("voteExpel" + player.getPlayer().getId(),
+ player.getPlayer().getNickname() + " (" + user.getUser().getName() + ")"));
+ }
}
Message message = channel.sendMessageEmbeds(embedBuilder.build())
.setComponents(MsgUtils.spreadButtonsAcrossActionRows(buttons).toArray(new ActionRow[0])).complete();
- CmdUtils.schedule(() -> Audio.play(Audio.Resource.POLL_10S_REMAINING, channel.getGuild().getVoiceChannelById(session.getCourtVoiceChannelId())), 20000);
+ CmdUtils.schedule(() -> Audio.play(Audio.Resource.POLL_10S_REMAINING,
+ channel.getGuild().getVoiceChannelById(session.getCourtVoiceChannelId())), 20000);
CmdUtils.schedule(() -> {
- List winners = Candidate.getWinner(expelCandidates.get(channel.getGuild().getIdLong()).values(), session.getPolice());
+ List winners = Candidate.getWinner(expelCandidates.get(channel.getGuild().getIdLong()).values(),
+ null);
if (winners.isEmpty()) {
- message.reply("沒有人投票,不驅逐").queue();
+ if (message != null)
+ message.reply("沒有人投票,本次驅逐無人出局").queue();
expelCandidates.remove(channel.getGuild().getIdLong());
+ return;
}
+
if (winners.size() == 1) {
- message.reply("投票已結束,<@!" + winners.getFirst().getPlayer().getUserId() + "> 遭到驅逐").queue();
+ Candidate winner = winners.getFirst();
+ if (message != null)
+ message.reply("投票已結束,正在放逐玩家 <@!" + winner.getPlayer().getUserId() + ">").queue();
EmbedBuilder resultEmbed = new EmbedBuilder().setTitle("驅逐投票").setColor(MsgUtils.getRandomColor())
- .setDescription("遭驅逐玩家: <@!" + winners.getFirst().getPlayer().getUserId() + ">");
+ .setDescription("放逐玩家: <@!" + winner.getPlayer().getUserId() + ">");
sendVoteResult(session, channel, message, resultEmbed, expelCandidates, false);
+
expelCandidates.remove(channel.getGuild().getIdLong());
- Player.playerDied(session, channel.getGuild().getMemberById(Objects.requireNonNull(winners.getFirst().getPlayer().getUserId())), true, true);
- }
- if (winners.size() > 1) {
+ } else {
if (allowPK) {
EmbedBuilder resultEmbed = new EmbedBuilder().setTitle("驅逐投票").setColor(MsgUtils.getRandomColor())
.setDescription("發生平票");
sendVoteResult(session, channel, message, resultEmbed, expelCandidates, false);
+
handleExpelPK(session, channel, message, winners);
} else {
- message.reply("平票第二次,不驅逐").queue();
EmbedBuilder resultEmbed = new EmbedBuilder().setTitle("驅逐投票").setColor(MsgUtils.getRandomColor())
- .setDescription("平票第二次,不驅逐");
+ .setDescription("再次發生平票,本次驅逐無人出局");
+ if (message != null)
+ message.reply("再次平票,無人出局").queue();
sendVoteResult(session, channel, message, resultEmbed, expelCandidates, false);
expelCandidates.remove(channel.getGuild().getIdLong());
}
@@ -93,270 +105,83 @@ public static void startExpelPoll(Session session, GuildMessageChannel channel,
}, 30000);
}
- public static void sendVoteResult(Session session, GuildMessageChannel channel, Message message, EmbedBuilder resultEmbed,
+ public static void sendVoteResult(Session session, GuildMessageChannel channel, Message message,
+ EmbedBuilder resultEmbed,
Map> candidates, boolean police) {
List voted = new LinkedList<>();
for (Candidate candidate : candidates.get(channel.getGuild().getIdLong()).values()) {
- if (candidate.isQuit()) continue;
- assert candidate.getPlayer().getUserId() != null;
- User user = WerewolfHelper.jda.getUserById(candidate.getPlayer().getUserId());
+ User user = WerewolfApplication.jda.getUserById(candidate.getPlayer().getUserId());
assert user != null;
voted.addAll(candidate.getElectors());
- resultEmbed.addField("玩家" + candidate.getPlayer().getId() + " (" + user.getName() + ")",
+ resultEmbed.addField(candidate.getPlayer().getNickname() + " (" + user.getName() + ")",
String.join("、", candidate.getElectorsAsMention()), false);
}
List discarded = new LinkedList<>();
- for (Session.Player player : session.getPlayers().values()) {
- if ((candidates.get(channel.getGuild().getIdLong()).get(player.getId()) == null || !police) &&
- !voted.contains(player.getUserId())) {
+ for (Session.Player player : session.fetchAlivePlayers().values()) {
+ if (!voted.contains(player.getUserId())) {
discarded.add("<@!" + player.getUserId() + ">");
}
}
- resultEmbed.addField("棄票玩家", String.join("、", discarded), false);
- message.editMessageEmbeds(resultEmbed.build()).queue();
+ resultEmbed.addField("棄票", discarded.isEmpty() ? "無" : String.join("、", discarded), false);
+ if (message != null)
+ message.getChannel().sendMessageEmbeds(resultEmbed.build()).queue();
+ else
+ channel.sendMessageEmbeds(resultEmbed.build()).queue();
}
- @Subcommand(description = "啟動放逐投票")
+ @Subcommand(description = "啟動驅逐投票")
public void expel(SlashCommandInteractionEvent event) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
+ if (!CmdUtils.isAdmin(event))
+ return;
Session session = CmdUtils.getSession(Objects.requireNonNull(event.getGuild()));
- if (session == null) return;
+ if (session == null)
+ return;
Map candidates = new ConcurrentHashMap<>();
- for (Session.Player player : session.getPlayers().values()) {
- candidates.put(player.getId(), Candidate.builder().player(player).build());
+ for (Session.Player p : session.getPlayers().values()) {
+ if (p.isAlive()) {
+ candidates.put(p.getId(), Candidate.builder().player(p).expelPK(true).build());
+ }
}
expelCandidates.put(event.getGuild().getIdLong(), candidates);
- startExpelPoll(session, event.getGuildChannel(), true);
+ startExpelPoll(session, (GuildMessageChannel) event.getChannel(), true);
event.getHook().editOriginal(":white_check_mark:").queue();
}
@Subcommand
public static class Police {
- public static Map allowEnroll = new ConcurrentHashMap<>(); // key is guild id
- public static Map allowUnEnroll = new ConcurrentHashMap<>(); // key is guild id
- public static Map> candidates = new ConcurrentHashMap<>(); // key is guild id // second key is candidate id
-
- public static void handlePolicePK(Session session, GuildMessageChannel channel, Message message, List winners) {
- message.reply("平票,請PK").queue();
- Map newCandidates = new ConcurrentHashMap<>();
- for (Candidate winner : winners) {
- winner.electors.clear();
- newCandidates.put(winner.getPlayer().getId(), winner);
- }
- candidates.put(channel.getGuild().getIdLong(), newCandidates);
- Speech.pollSpeech(channel.getGuild(), message, newCandidates.values().stream().map(Candidate::getPlayer).toList(),
- () -> startPolicePoll(session, channel, false));
- }
-
- public static void startPolicePoll(Session session, GuildMessageChannel channel, boolean allowPK) {
- allowUnEnroll.put(channel.getGuild().getIdLong(), false);
- Audio.play(Audio.Resource.POLICE_POLL, channel.getGuild().getVoiceChannelById(session.getCourtVoiceChannelId()));
- allowEnroll.put(channel.getGuild().getIdLong(), true);
- EmbedBuilder embedBuilder = new EmbedBuilder().setTitle("警長投票").setDescription("30秒後立刻計票,請加快手速!\n若要改票可直接按下要改成的對象\n若要改為棄票需按下原本投給的使用者").setColor(MsgUtils.getRandomColor());
- List buttons = new LinkedList<>();
- for (Candidate player : candidates.get(channel.getGuild().getIdLong()).values()
- .stream().sorted(Candidate.getComparator()).toList()) {
- assert player.getPlayer().getRoles() != null;
- if (player.getPlayer().getRoles().isEmpty()) continue;
- if (player.isQuit()) continue;
- assert player.getPlayer().getUserId() != null;
- Member user = channel.getGuild().getMemberById(player.getPlayer().getUserId());
- assert user != null;
- buttons.add(Button.primary("votePolice" + player.getPlayer().getId(),
- "玩家" + player.getPlayer().getId() + " (" + user.getUser().getName() + ")"));
- }
- Message message = channel.sendMessageEmbeds(embedBuilder.build())
- .setComponents(MsgUtils.spreadButtonsAcrossActionRows(buttons).toArray(new ActionRow[0])).complete();
- CmdUtils.schedule(() -> Audio.play(Audio.Resource.POLL_10S_REMAINING, channel.getGuild().getVoiceChannelById(session.getCourtVoiceChannelId())), 20000);
- CmdUtils.schedule(() -> {
- List winners = Candidate.getWinner(candidates.get(channel.getGuild().getIdLong()).values(), null);
- if (winners.isEmpty()) {
- message.reply("沒有人投票,警徽撕毀").queue();
- candidates.remove(channel.getGuild().getIdLong());
- return;
- }
- if (winners.size() == 1) {
- message.reply("投票已結束,<@!" + winners.getFirst().getPlayer().getUserId() + "> 獲勝").queue();
-
- EmbedBuilder resultEmbed = new EmbedBuilder().setTitle("警長投票").setColor(MsgUtils.getRandomColor())
- .setDescription("獲勝玩家: <@!" + winners.getFirst().getPlayer().getUserId() + ">");
- sendVoteResult(session, channel, message, resultEmbed, candidates, true);
- candidates.remove(channel.getGuild().getIdLong());
- Member member = channel.getGuild().getMemberById(Objects.requireNonNull(winners.getFirst().getPlayer().getUserId()));
- if (member != null)
- member.modifyNickname(member.getEffectiveName() + " [警長]").queue();
- Session.fetchCollection().updateOne(eq("guildId", channel.getGuild().getIdLong()),
- set("players." + winners.getFirst().getPlayer().getId() + ".police", true));
- }
- if (winners.size() > 1) {
- if (allowPK) {
- EmbedBuilder resultEmbed = new EmbedBuilder().setTitle("警長投票").setColor(MsgUtils.getRandomColor())
- .setDescription("發生平票");
- sendVoteResult(session, channel, message, resultEmbed, candidates, true);
- handlePolicePK(session, channel, message, winners);
- } else {
- EmbedBuilder resultEmbed = new EmbedBuilder().setTitle("警長投票").setColor(MsgUtils.getRandomColor())
- .setDescription("平票第二次,警徽撕毀");
- message.reply("平票第二次,警徽撕毀").queue();
- sendVoteResult(session, channel, message, resultEmbed, candidates, true);
- candidates.remove(channel.getGuild().getIdLong());
- }
- }
- }, 30000);
- }
-
- @dev.robothanzo.jda.interactions.annotations.Button()
- public void enrollPolice(ButtonInteractionEvent event) {
- event.deferReply(true).queue();
- Session session = CmdUtils.getSession(Objects.requireNonNull(event.getGuild()));
- if (session == null) {
- return;
- }
- for (Map.Entry candidate : new LinkedList<>(candidates.get(event.getGuild().getIdLong()).entrySet())) {
- if (Objects.equals(event.getUser().getIdLong(), candidate.getValue().getPlayer().getUserId())) {
- if (allowEnroll.get(event.getGuild().getIdLong()) && allowUnEnroll.get(event.getGuild().getIdLong())) { // The enrollment process hasn't ended yet, so we remove them completely
- candidates.get(event.getGuild().getIdLong()).remove(candidate.getKey());
- event.getHook().editOriginal(":white_check_mark: 已取消參選").queue();
- } else if (allowUnEnroll.get(event.getGuild().getIdLong())) {
- candidates.get(event.getGuild().getIdLong()).get(candidate.getKey()).setQuit(true);
- event.getHook().editOriginal(":white_check_mark: 已取消參選").queue();
- Objects.requireNonNull(event.getGuild().getTextChannelById(session.getCourtTextChannelId()))
- .sendMessage(event.getUser().getAsMention() + " 已取消參選").queue();
- } else {
- event.getHook().editOriginal(":x: 無法取消參選,投票已開始").queue();
- }
- return;
- }
- }
- if ((!allowEnroll.containsKey(event.getGuild().getIdLong())) || !allowEnroll.get(event.getGuild().getIdLong())) {
- event.getHook().editOriginal(":x: 無法參選,時間已到").queue();
- }
- for (Session.Player player : session.getPlayers().values()) {
- if (Objects.equals(event.getUser().getIdLong(), player.getUserId())) {
- candidates.get(event.getGuild().getIdLong()).put(player.getId(), Candidate.builder().player(player).build());
- event.getHook().editOriginal(":white_check_mark: 已參選").queue();
- return;
- }
- }
- event.getHook().editOriginal(":x: 你不是玩家").queue();
- }
-
@Subcommand(description = "啟動警長參選投票")
public void enroll(SlashCommandInteractionEvent event) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
+ if (!CmdUtils.isAdmin(event))
+ return;
Session session = CmdUtils.getSession(Objects.requireNonNull(event.getGuild()));
- if (session == null) return;
- candidates.put(event.getGuild().getIdLong(), new ConcurrentHashMap<>());
- allowEnroll.put(event.getGuild().getIdLong(), true);
- allowUnEnroll.put(event.getGuild().getIdLong(), true);
- Audio.play(Audio.Resource.POLICE_ENROLL, event.getGuild().getVoiceChannelById(session.getCourtVoiceChannelId()));
- EmbedBuilder embed = new EmbedBuilder()
- .setTitle("參選警長").setDescription("30秒後立刻進入辯論,請加快手速!").setColor(MsgUtils.getRandomColor());
- Message message = event.getHook().editOriginalEmbeds(embed.build())
- .setComponents(ActionRow.of(Button.success("enrollPolice", "參選警長")))
- .complete();
- CmdUtils.schedule(() -> Audio.play(Audio.Resource.ENROLL_10S_REMAINING, event.getGuild().getVoiceChannelById(session.getCourtVoiceChannelId())), 20000);
- CmdUtils.schedule(() -> {
- allowEnroll.put(event.getGuild().getIdLong(), false);
- if (candidates.get(event.getGuild().getIdLong()).isEmpty()) {
- candidates.remove(event.getGuild().getIdLong());
- message.reply("無人參選,警徽撕毀").queue();
- return;
- }
- List candidateMentions = new LinkedList<>();
- for (Candidate candidate : candidates.get(event.getGuild().getIdLong()).values().stream().sorted(Candidate.getComparator()).toList()) {
- candidateMentions.add("<@!" + candidate.getPlayer().getUserId() + ">");
- }
- if (candidates.get(event.getGuild().getIdLong()).size() == 1) {
- message.reply("只有" + candidateMentions.getFirst() + "參選,直接當選").queue();
- Member member = event.getGuild().getMemberById(Objects.requireNonNull(candidates.get(event.getGuild().getIdLong()).get(0).getPlayer().getUserId()));
- if (member != null)
- member.modifyNickname(member.getEffectiveName() + " [警長]").queue();
- Session.fetchCollection().updateOne(eq("guildId", event.getGuild().getIdLong()),
- set("players." + candidates.get(event.getGuild().getIdLong()).get(0).getPlayer().getId() + ".police", true));
- candidates.remove(event.getGuild().getIdLong());
- return;
- }
- message.replyEmbeds(new EmbedBuilder().setTitle("參選警長結束")
- .setDescription("參選的有: " + String.join("、", candidateMentions) + "\n備註:你可隨時再按一次按鈕以取消參選")
- .setColor(MsgUtils.getRandomColor()).build()).complete();
- Speech.pollSpeech(event.getGuild(), message, candidates.get(event.getGuild().getIdLong()).values().stream().map(Candidate::getPlayer).toList(),
- () -> {
- message.getChannel().sendMessage("政見發表結束,參選人有20秒進行退選,20秒後將自動開始投票").queue();
- CmdUtils.schedule(() -> startPolicePoll(session, event.getGuildChannel(), true), 20000);
- });
- }, 30000);
+ if (session == null)
+ return;
+
+ if (WerewolfApplication.policeService.getSessions().containsKey(event.getGuild().getIdLong())) {
+ event.getHook().editOriginal(":x: 警長選舉已在進行中").queue();
+ return;
+ }
+
+ WerewolfApplication.policeService.startEnrollment(session, (GuildMessageChannel) event.getChannel(), null);
event.getHook().editOriginal(":white_check_mark:").queue();
}
@Subcommand(description = "啟動警長投票 (會自動開始,請只在出問題時使用)")
public void start(SlashCommandInteractionEvent event) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
- Session session = CmdUtils.getSession(Objects.requireNonNull(event.getGuild()));
- if (session == null) return;
- startPolicePoll(session, event.getGuildChannel(), true);
- }
- }
-
- @Data
- @Builder
- public static class Candidate {
- private Session.Player player;
- @Builder.Default
- private boolean expelPK = false;
- @Builder.Default
- private List electors = new LinkedList<>();
- @Builder.Default
- private boolean quit = false;
-
- public static Comparator getComparator() {
- return Comparator.comparingInt(o -> o.getPlayer().getId());
- }
-
- public static List getWinner(Collection candidates, @Nullable Session.Player police) {
- List winners = new LinkedList<>();
- float winningVotes = 0;
- for (Candidate candidate : candidates) {
- float votes = candidate.getVotes(police);
- if (votes <= 0) continue;
- if (votes > winningVotes) {
- winningVotes = votes;
- winners.clear();
- winners.add(candidate);
- } else if (votes == winningVotes) {
- winners.add(candidate);
- }
- }
- return winners;
- }
+ if (!CmdUtils.isAdmin(event))
+ return;
- public List getElectorsAsMention() {
- List result = new LinkedList<>();
- for (Long elector : electors) {
- result.add("<@!" + elector + ">");
- }
- return result;
+ WerewolfApplication.policeService.forceStartVoting(event.getGuild().getIdLong());
+ event.getHook().editOriginal(":white_check_mark:").queue();
}
- public float getVotes(@Nullable Session.Player police) {
- boolean hasPolice = police != null;
- if (hasPolice) hasPolice = electors.contains(police.getUserId());
- return (float) ((electors.size() + (hasPolice ? 0.5 : 0)) * (quit ? 0 : 1));
+ @dev.robothanzo.jda.interactions.annotations.Button()
+ public void enrollPolice(ButtonInteractionEvent event) {
+ WerewolfApplication.policeService.enrollPolice(event);
}
}
-
- public static void sendRolesList(ButtonInteractionEvent event) {
- Session session = CmdUtils.getSession(event);
- if (session == null) return;
- List roles = session.getRoles();
- event.replyEmbeds(new EmbedBuilder()
- .setTitle("剩餘身分")
- .setDescription(roles.isEmpty() ? "無" : String.join("\n", roles))
- .setColor(MsgUtils.getRandomColor())
- .build()).setEphemeral(true).queue();
- }
}
diff --git a/src/main/java/dev/robothanzo/werewolf/commands/Server.java b/src/main/java/dev/robothanzo/werewolf/commands/Server.java
index 4e7c178..bd587d5 100644
--- a/src/main/java/dev/robothanzo/werewolf/commands/Server.java
+++ b/src/main/java/dev/robothanzo/werewolf/commands/Server.java
@@ -5,7 +5,7 @@
import dev.robothanzo.jda.interactions.annotations.slash.Subcommand;
import dev.robothanzo.jda.interactions.annotations.slash.options.AutoCompleter;
import dev.robothanzo.jda.interactions.annotations.slash.options.Option;
-import dev.robothanzo.werewolf.WerewolfHelper;
+import dev.robothanzo.werewolf.WerewolfApplication;
import dev.robothanzo.werewolf.database.documents.Session;
import dev.robothanzo.werewolf.utils.CmdUtils;
import dev.robothanzo.werewolf.utils.MsgUtils;
@@ -38,15 +38,18 @@ public class Server {
public void create(SlashCommandInteractionEvent event,
@Option(value = "players", description = "玩家數量") Long players,
@Option(value = "double_identity", description = "是否為雙身分模式,預設否", optional = true) Boolean doubleIdentity) {
- if (!CmdUtils.isServerCreator(event)) return;
+ if (!CmdUtils.isServerCreator(event))
+ return;
long userId = event.getUser().getIdLong();
boolean doubleId = doubleIdentity != null && doubleIdentity;
- // store the channel where user invoked the command so we can send the invite back there later
+ // store the channel where user invoked the command so we can send the invite
+ // back there later
long originChannelId = event.getChannel().getIdLong();
pendingSetups.put(userId, new PendingSetup(Math.toIntExact(players), doubleId, originChannelId));
- String inviteUrl = WerewolfHelper.jda.getInviteUrl(Permission.ADMINISTRATOR).replaceAll("scope=bot", "scope=bot%20applications.commands");
+ String inviteUrl = WerewolfApplication.jda.getInviteUrl(Permission.ADMINISTRATOR).replaceAll("scope=bot",
+ "scope=bot%20applications.commands");
EmbedBuilder eb = new EmbedBuilder()
.setTitle("狼人殺伺服器建立指南")
@@ -61,17 +64,20 @@ public void create(SlashCommandInteractionEvent event,
}
@Subcommand(description = "刪除所在之伺服器(僅可在狼人殺伺服器內使用)")
- public void delete(SlashCommandInteractionEvent event, @Option(value = "guild_id", optional = true) String guildId) {
+ public void delete(SlashCommandInteractionEvent event,
+ @Option(value = "guild_id", optional = true) String guildId) {
try {
- if (!CmdUtils.isServerCreator(event)) return;
+ if (!CmdUtils.isServerCreator(event))
+ return;
if (guildId == null) {
Objects.requireNonNull(event.getGuild()).leave().queue();
Session.fetchCollection().deleteOne(eq("guildId", event.getGuild().getIdLong()));
- Speech.speechSessions.remove(event.getGuild().getIdLong());
+ WerewolfApplication.speechService.interruptSession(event.getGuild().getIdLong());
} else {
- Objects.requireNonNull(WerewolfHelper.jda.getGuildById(guildId)).leave().queue();
+ Objects.requireNonNull(WerewolfApplication.jda.getGuildById(guildId)).leave().queue();
Session.fetchCollection().deleteOne(eq("guildId", guildId));
- Speech.speechSessions.remove(Objects.requireNonNull(WerewolfHelper.jda.getGuildById(guildId)).getIdLong());
+ WerewolfApplication.speechService.interruptSession(
+ Objects.requireNonNull(WerewolfApplication.jda.getGuildById(guildId)).getIdLong());
}
event.reply(":white_check_mark:").queue();
} catch (Exception e) {
@@ -81,9 +87,10 @@ public void delete(SlashCommandInteractionEvent event, @Option(value = "guild_id
@Subcommand(description = "列出所在之伺服器")
public void list(SlashCommandInteractionEvent event) {
- if (!CmdUtils.isAuthor(event)) return;
+ if (!CmdUtils.isAuthor(event))
+ return;
StringBuilder sb = new StringBuilder();
- for (Guild guild : WerewolfHelper.jda.getGuilds()) {
+ for (Guild guild : WerewolfApplication.jda.getGuilds()) {
sb.append(guild.getName())
.append(" (").append(guild.getId()).append(")\n");
}
@@ -92,9 +99,10 @@ public void list(SlashCommandInteractionEvent event) {
@Subcommand(description = "列出所在之伺服器")
public void lists(SlashCommandInteractionEvent event) {
- if (!CmdUtils.isAuthor(event)) return;
+ if (!CmdUtils.isAuthor(event))
+ return;
StringBuilder sb = new StringBuilder();
- for (Guild guild : WerewolfHelper.jda.getGuilds()) {
+ for (Guild guild : WerewolfApplication.jda.getGuilds()) {
sb.append(guild.getName())
.append(" (").append(guild.getId()).append(")\n");
}
@@ -103,9 +111,11 @@ public void lists(SlashCommandInteractionEvent event) {
@SneakyThrows
@Subcommand
- public void session(SlashCommandInteractionEvent event, @Option(value = "guild_id", optional = true) String guildId) {
+ public void session(SlashCommandInteractionEvent event,
+ @Option(value = "guild_id", optional = true) String guildId) {
event.deferReply().queue();
- if (!CmdUtils.isAuthor(event)) return;
+ if (!CmdUtils.isAuthor(event))
+ return;
long gid;
if (guildId == null) {
if (event.getGuild() == null) {
@@ -123,11 +133,30 @@ public void session(SlashCommandInteractionEvent event, @Option(value = "guild_i
} else {
EmbedBuilder eb = new EmbedBuilder()
.setTitle("戰局資訊")
- .setDescription("```json\n" + new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(session) + "\n```");
+ .setDescription("```json\n"
+ + new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(session)
+ + "\n```");
event.getHook().editOriginalEmbeds(eb.build()).queue();
}
}
+ @Subcommand(description = "取得管理面板連結")
+ public void dashboard(SlashCommandInteractionEvent event) {
+ if (!CmdUtils.isAdmin(event))
+ return;
+
+ Guild guild = event.getGuild();
+ if (guild == null) {
+ event.reply(":x: 此指令僅能在伺服器中使用").setEphemeral(true).queue();
+ return;
+ }
+
+ String dashboardUrl = System.getenv().getOrDefault("DASHBOARD_URL", "http://localhost:5173");
+ String fullUrl = dashboardUrl + "/server/" + guild.getId();
+
+ event.reply("管理面板連結:" + fullUrl).setEphemeral(false).queue();
+ }
+
public record PendingSetup(int players, boolean doubleIdentity, long originChannelId) {
}
@@ -143,7 +172,7 @@ public static List expandStringToList(String s, int amount) {
@AutoCompleter
public void role(CommandAutoCompleteInteractionEvent event) {
- event.replyChoices(WerewolfHelper.ROLES.stream()
+ event.replyChoices(WerewolfApplication.ROLES.stream()
.filter(s -> s.startsWith(event.getFocusedOption().getValue()))
.limit(25)
.map(s -> new Choice(s, s))
@@ -154,7 +183,8 @@ public void role(CommandAutoCompleteInteractionEvent event) {
public void existingRole(CommandAutoCompleteInteractionEvent event) {
List choices = new LinkedList<>();
Session session = CmdUtils.getSession(event.getGuild());
- if (session == null) return;
+ if (session == null)
+ return;
for (String role : session.getRoles()) {
if (role.startsWith(event.getFocusedOption().getValue())) {
choices.add(new Choice(role, role));
@@ -167,7 +197,8 @@ public void existingRole(CommandAutoCompleteInteractionEvent event) {
public void list(SlashCommandInteractionEvent event) {
event.deferReply().queue();
Session session = CmdUtils.getSession(event);
- if (session == null) return;
+ if (session == null)
+ return;
EmbedBuilder embedBuilder = new EmbedBuilder().setTitle("角色清單").setColor(MsgUtils.getRandomColor());
Map roles = new HashMap<>();
int rolesCount = 0;
@@ -181,7 +212,9 @@ public void list(SlashCommandInteractionEvent event) {
if (rolesCount == session.getPlayers().size() * (session.isDoubleIdentities() ? 2 : 1)) {
embedBuilder.setDescription(":white_check_mark: 角色數量正確");
} else {
- embedBuilder.setDescription(":x: 角色數量錯誤,應有 *" + session.getPlayers().size() * (session.isDoubleIdentities() ? 2 : 1) + "* 個角色,現有 *" + rolesCount + "* 個");
+ embedBuilder.setDescription(
+ ":x: 角色數量錯誤,應有 *" + session.getPlayers().size() * (session.isDoubleIdentities() ? 2 : 1)
+ + "* 個角色,現有 *" + rolesCount + "* 個");
}
event.getHook().editOriginalEmbeds(embedBuilder.build()).queue();
}
@@ -190,23 +223,30 @@ public void list(SlashCommandInteractionEvent event) {
public void add(SlashCommandInteractionEvent event, @Option(value = "role", autoComplete = true) String role,
@Option(value = "amount", optional = true) Long amount) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
- if (amount == null) amount = 1L;
+ if (!CmdUtils.isAdmin(event))
+ return;
+ if (amount == null)
+ amount = 1L;
Session session = CmdUtils.getSession(event);
- if (session == null) return;
+ if (session == null)
+ return;
Session.fetchCollection().updateOne(eq("guildId", session.getGuildId()),
pushEach("roles", expandStringToList(role, amount.intValue())));
event.getHook().editOriginal(":white_check_mark:").queue();
}
@Subcommand(description = "刪除角色")
- public void delete(SlashCommandInteractionEvent event, @Option(value = "role", autoComplete = true, autoCompleter = "existingRole") String role,
+ public void delete(SlashCommandInteractionEvent event,
+ @Option(value = "role", autoComplete = true, autoCompleter = "existingRole") String role,
@Option(value = "amount", optional = true) Long amount) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
- if (amount == null) amount = 1L;
+ if (!CmdUtils.isAdmin(event))
+ return;
+ if (amount == null)
+ amount = 1L;
Session session = CmdUtils.getSession(event);
- if (session == null) return;
+ if (session == null)
+ return;
List roles = session.getRoles();
for (int i = 0; i < amount; i++) {
roles.remove(role);
@@ -222,9 +262,11 @@ public static class Set {
@Subcommand(description = "設定是否為雙身分局")
public void double_identities(SlashCommandInteractionEvent event, @Option(value = "value") Boolean value) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
+ if (!CmdUtils.isAdmin(event))
+ return;
Session session = CmdUtils.getSession(event);
- if (session == null) return;
+ if (session == null)
+ return;
Session.fetchCollection().updateOne(
eq("guildId", Objects.requireNonNull(event.getGuild()).getIdLong()),
set("doubleIdentities", value));
@@ -234,9 +276,11 @@ public void double_identities(SlashCommandInteractionEvent event, @Option(value
@Subcommand(description = "設定是否在發言後將玩家靜音")
public void mute_after_speech(SlashCommandInteractionEvent event, @Option(value = "value") Boolean value) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
+ if (!CmdUtils.isAdmin(event))
+ return;
Session session = CmdUtils.getSession(event);
- if (session == null) return;
+ if (session == null)
+ return;
Session.fetchCollection().updateOne(
eq("guildId", Objects.requireNonNull(event.getGuild()).getIdLong()),
set("muteAfterSpeech", value));
@@ -246,9 +290,11 @@ public void mute_after_speech(SlashCommandInteractionEvent event, @Option(value
@Subcommand(description = "設定總玩家數量")
public void players(SlashCommandInteractionEvent event, @Option(value = "value") Long value) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
+ if (!CmdUtils.isAdmin(event))
+ return;
Session session = CmdUtils.getSession(event);
- if (session == null) return;
+ if (session == null)
+ return;
assert event.getGuild() != null;
Map players = session.getPlayers();
try {
@@ -256,24 +302,31 @@ public void players(SlashCommandInteractionEvent event, @Option(value = "value")
if (player.getId() > value) {
players.remove(String.valueOf(player.getId()));
Objects.requireNonNull(event.getGuild().getRoleById(player.getRoleId())).delete().queue();
- Objects.requireNonNull(event.getGuild().getTextChannelById(player.getChannelId())).delete().queue();
+ Objects.requireNonNull(event.getGuild().getTextChannelById(player.getChannelId())).delete()
+ .queue();
}
}
for (long i = players.size() + 1; i <= value; i++) {
- Role role = event.getGuild().createRole().setColor(MsgUtils.getRandomColor()).setHoisted(true).setName("玩家" + i).complete();
- TextChannel channel = event.getGuild().createTextChannel("玩家" + i)
- .addPermissionOverride(Objects.requireNonNull(event.getGuild().getRoleById(session.getSpectatorRoleId())),
+ Role role = event.getGuild().createRole().setColor(MsgUtils.getRandomColor()).setHoisted(true)
+ .setName("玩家" + Session.Player.ID_FORMAT.format(i)).complete();
+ TextChannel channel = event.getGuild().createTextChannel("玩家" + Session.Player.ID_FORMAT.format(i))
+ .addPermissionOverride(
+ Objects.requireNonNull(event.getGuild().getRoleById(session.getSpectatorRoleId())),
Permission.VIEW_CHANNEL.getRawValue(), Permission.MESSAGE_SEND.getRawValue())
- .addPermissionOverride(role, List.of(Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND), List.of())
- .addPermissionOverride(event.getGuild().getPublicRole(), List.of(), List.of(Permission.VIEW_CHANNEL,
- Permission.MESSAGE_SEND, Permission.USE_APPLICATION_COMMANDS)).complete();
+ .addPermissionOverride(role, List.of(Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND),
+ List.of())
+ .addPermissionOverride(event.getGuild().getPublicRole(), List.of(),
+ List.of(Permission.VIEW_CHANNEL,
+ Permission.MESSAGE_SEND, Permission.USE_APPLICATION_COMMANDS))
+ .complete();
players.put(String.valueOf(i), Session.Player.builder()
.id((int) i)
.roleId(role.getIdLong())
.channelId(channel.getIdLong())
.build());
}
- Session.fetchCollection().updateOne(eq("guildId", event.getGuild().getIdLong()), set("players", players));
+ Session.fetchCollection().updateOne(eq("guildId", event.getGuild().getIdLong()),
+ set("players", players));
} catch (Exception e) {
log.error("Failed to update player amount", e);
event.getHook().editOriginal(":x: 因為未知原因而無法更新玩家人數").queue();
diff --git a/src/main/java/dev/robothanzo/werewolf/commands/Speech.java b/src/main/java/dev/robothanzo/werewolf/commands/Speech.java
index a77f556..b91ff7c 100644
--- a/src/main/java/dev/robothanzo/werewolf/commands/Speech.java
+++ b/src/main/java/dev/robothanzo/werewolf/commands/Speech.java
@@ -1,463 +1,120 @@
package dev.robothanzo.werewolf.commands;
-import com.mongodb.client.model.Filters;
import dev.robothanzo.jda.interactions.annotations.slash.Command;
import dev.robothanzo.jda.interactions.annotations.slash.Subcommand;
import dev.robothanzo.jda.interactions.annotations.slash.options.Option;
-import dev.robothanzo.werewolf.WerewolfHelper;
-import dev.robothanzo.werewolf.audio.Audio;
+import dev.robothanzo.werewolf.WerewolfApplication;
import dev.robothanzo.werewolf.database.documents.Session;
import dev.robothanzo.werewolf.utils.CmdUtils;
-import dev.robothanzo.werewolf.utils.MsgUtils;
-import lombok.Builder;
-import lombok.Data;
-import net.dv8tion.jda.api.EmbedBuilder;
-import net.dv8tion.jda.api.Permission;
-import net.dv8tion.jda.api.entities.Guild;
-import net.dv8tion.jda.api.entities.Member;
-import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.channel.Channel;
-import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
-import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
-import net.dv8tion.jda.api.entities.emoji.Emoji;
+import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
-import net.dv8tion.jda.api.components.actionrow.ActionRow;
-import net.dv8tion.jda.api.components.buttons.Button;
-import net.dv8tion.jda.api.components.selections.StringSelectMenu;
-import net.dv8tion.jda.api.utils.TimeFormat;
-import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.NotNull;
import java.time.Duration;
-import java.util.*;
+import java.util.Objects;
+@Slf4j
@Command
public class Speech {
- public static Map speechSessions = new HashMap<>();
- public static Map timers = new HashMap<>();
- public static void pollSpeech(Guild guild, Message enrollMessage, Collection players,
- @Nullable Runnable callback) {
- speechSessions.put(guild.getIdLong(), SpeechSession.builder()
- .guildId(guild.getIdLong())
- .channelId(enrollMessage.getChannel().getIdLong())
- .session(Session.fetchCollection().find(Filters.eq("guildId", guild.getIdLong())).first())
- .finishedCallback(callback)
- .build());
- Order order = Order.getRandomOrder();
- List shuffledPlayers = new LinkedList<>(players);
- Collections.shuffle(shuffledPlayers);
- Session.Player target = shuffledPlayers.getFirst();
- enrollMessage.replyEmbeds(new EmbedBuilder().setTitle("隨機抽取投票辯論順序")
- .setDescription("抽到的順序: 玩家" + shuffledPlayers.getFirst().getId() + order.toString())
- .setColor(MsgUtils.getRandomColor()).build()).queue();
- changeOrder(guild, order, players, target);
- speechSessions.get(guild.getIdLong()).next();
- }
-
- public static void lastWordsSpeech(Guild guild, Channel channel, Session.Player player, @Nullable Runnable callback) {
- List order = new LinkedList<>(); // must use this to make it modifiable
- order.add(player);
- speechSessions.put(guild.getIdLong(), SpeechSession.builder()
- .guildId(guild.getIdLong())
- .channelId(channel.getIdLong())
- .session(Session.fetchCollection().find(Filters.eq("guildId", guild.getIdLong())).first())
- .order(order)
- .finishedCallback(callback)
- .build());
- speechSessions.get(guild.getIdLong()).next();
- }
-
- public static void changeOrder(Guild guild, Order order, Collection playersRaw, Session.Player target) {
- var players = new LinkedList<>(playersRaw);
- Collections.sort(players);
- List prePolice = new LinkedList<>(); // 1 2 3
- Session.Player police = null; // 4
- List postPolice = new LinkedList<>(); // 5 6 7
- for (Session.Player player : players) {
- if (player.getId() == target.getId()) {
- police = player;
- continue;
- }
- if (police == null) {
- prePolice.add(player);
- } else {
- postPolice.add(player);
- }
- }
- List orderList = new LinkedList<>();
- if (order == Order.UP) {
- Collections.reverse(prePolice);
- orderList.addAll(prePolice);
- Collections.reverse(postPolice);
- orderList.addAll(postPolice);
- orderList.add(police);
- } else {
- orderList.addAll(postPolice);
- orderList.addAll(prePolice);
- orderList.add(police);
- }
- speechSessions.get(guild.getIdLong()).setOrder(orderList);
- }
-
- public static void terminateTimer(ButtonInteractionEvent event) {
- event.deferReply(true).queue();
- if (timers.containsKey(event.getChannel().getIdLong()) && CmdUtils.isAdmin(event)) {
- timers.get(event.getChannel().getIdLong()).interrupt();
- event.getHook().editOriginal(":white_check_mark:").queue();
- } else {
- event.getHook().editOriginal(":x:").queue();
- }
- }
-
- @dev.robothanzo.jda.interactions.annotations.select.StringSelectMenu
- public void selectOrder(StringSelectInteractionEvent event) {
- event.deferReply(true).queue();
- Session session = CmdUtils.getSession(Objects.requireNonNull(event.getGuild()));
- Order order = Order.valueOf(event.getSelectedOptions().getFirst().getValue().toUpperCase(Locale.ROOT));
- if (session == null) return;
- if (!speechSessions.containsKey(event.getGuild().getIdLong())) {
- event.getHook().editOriginal("法官尚未開始發言流程").queue();
+ @Subcommand(description = "開始自動發言流程")
+ public void auto(SlashCommandInteractionEvent event) {
+ event.deferReply(false).queue();
+ if (!CmdUtils.isAdmin(event))
return;
- }
- Session.Player target = null;
- for (Session.Player player : session.getPlayers().values()) {
- assert player.getUserId() != null;
- if (player.getUserId() == event.getUser().getIdLong() && !player.isPolice()) {
- event.getHook().editOriginal(":x: 你不是警長").queue();
- return;
- }
- if (player.isPolice()) {
- target = player;
- }
- }
- changeOrder(event.getGuild(), order, session.getPlayers().values(), target);
- event.getHook().editOriginal(":white_check_mark: 請按下確認以開始發言流程").queue();
- event.getMessage().editMessageEmbeds(new EmbedBuilder(event.getInteraction().getMessage().getEmbeds().getFirst())
- .setDescription("警長已選擇 " + order.toEmoji().getName() + " " + order + "\n請按下確認").build()).queue();
- }
- @dev.robothanzo.jda.interactions.annotations.Button
- public void confirmOrder(ButtonInteractionEvent event) {
- event.deferReply(true).queue();
- Session session = CmdUtils.getSession(Objects.requireNonNull(event.getGuild()));
- if (session == null) return;
- if (!speechSessions.containsKey(Objects.requireNonNull(event.getGuild()).getIdLong())) {
- event.getHook().editOriginal(":x: 法官尚未開始發言流程").queue();
+ Session session = CmdUtils.getSession(event);
+ if (session == null)
return;
- }
- SpeechSession speechSession = speechSessions.get(Objects.requireNonNull(event.getGuild()).getIdLong());
- boolean check = false;
- for (Session.Player player : session.getPlayers().values()) {
- assert player.getUserId() != null;
- if (player.getUserId() == event.getUser().getIdLong()) {
- if (player.isPolice()) {
- check = true;
- break;
- } else {
- event.getHook().editOriginal(":x: 你不是警長").queue();
- return;
- }
- }
- }
- if (!check) {
- event.getHook().editOriginal(":x: 你不是警長").queue();
- } else {
- if (speechSession.getOrder().isEmpty()) {
- event.getHook().editOriginal(":x: 請先選取往上或往下").queue();
- } else {
- speechSession.next();
- event.getHook().editOriginal(":white_check_mark: 確認完成").queue();
- }
- }
- }
-
- @dev.robothanzo.jda.interactions.annotations.Button
- public void skipSpeech(ButtonInteractionEvent event) {
- event.deferReply().queue();
- if (event.getGuild() != null && speechSessions.containsKey(event.getGuild().getIdLong())) {
- SpeechSession session = speechSessions.get(event.getGuild().getIdLong());
- if (session.getLastSpeaker() != null && event.getUser().getIdLong() != session.getLastSpeaker()) {
- event.getHook().setEphemeral(true).editOriginal(":x: 你不是發言者").queue();
- } else {
- event.getHook().editOriginal(":white_check_mark: 發言已跳過").queue();
- session.next();
- }
- } else {
- event.getHook().setEphemeral(true).editOriginal(":x: 法官尚未開始發言流程").queue();
- }
- }
- @dev.robothanzo.jda.interactions.annotations.Button
- public void interruptSpeech(ButtonInteractionEvent event) {
- event.deferReply(true).queue();
- if (event.getGuild() != null && speechSessions.containsKey(event.getGuild().getIdLong())) {
- SpeechSession session = speechSessions.get(event.getGuild().getIdLong());
- if (session.getLastSpeaker() != null && !Objects.requireNonNull(event.getMember()).hasPermission(Permission.ADMINISTRATOR)) {
- if (event.getUser().getIdLong() == session.getLastSpeaker()) {
- event.getHook().editOriginal(":x: 若要跳過發言請按左邊的跳過按鈕").queue();
- } else {
- Session gameSession = Objects.requireNonNull(CmdUtils.getSession(event));
- if (event.getMember().getRoles().contains(event.getGuild().getRoleById(gameSession.getSpectatorRoleId()))) {
- event.getHook().editOriginal(":x: 旁觀者不得投票").queue();
- } else {
- if (session.getInterruptVotes().contains(event.getUser().getIdLong())) {
- event.getHook().editOriginal(":white_check_mark: 成功取消下台投票,距離該玩家下台還缺" +
- (gameSession.getPlayers().size() / 2 - session.getInterruptVotes().size()) + "票").queue();
- } else {
- event.getHook().editOriginal(":white_check_mark: 下台投票成功,距離該玩家下台還缺" +
- (gameSession.getPlayers().size() / 2 - session.getInterruptVotes().size()) + "票").queue();
- session.getInterruptVotes().add(event.getUser().getIdLong());
- if (session.getInterruptVotes().size() > (gameSession.getPlayers().size() / 2)) {
- List voterMentions = new LinkedList<>();
- for (long voter : session.getInterruptVotes()) {
- voterMentions.add("<@!" + voter + ">");
- }
- event.getMessage().reply("人民的法槌已強制該玩家下台,有投票的有: " + String.join("、", voterMentions)).queue();
- session.next();
- }
- }
- }
- }
- } else {
- event.getHook().editOriginal(":white_check_mark: 成功強制下台").queue();
- event.getMessage().reply("法官已強制該玩家下台").queue();
- session.next();
- }
- } else {
- event.getHook().editOriginal(":x: 法官尚未開始發言流程").queue();
- }
+ WerewolfApplication.speechService.startAutoSpeechFlow(event.getGuild().getIdLong(),
+ event.getChannel().getIdLong());
+ event.getHook().editOriginal(":white_check_mark:").queue();
}
@Subcommand(description = "開始新的計時 (不是自動發言流程)")
- public void start(SlashCommandInteractionEvent event, @Option(value = "time", description = "計時時間(m為分鐘s為秒數,例: 10m、10s、1m30s)") Duration time) {
- if (!CmdUtils.isAdmin(event)) return;
+ public void start(SlashCommandInteractionEvent event,
+ @Option(value = "time", description = "計時時間(m為分鐘s為秒數,例: 10m、10s、1m30s)") Duration time) {
+ if (!CmdUtils.isAdmin(event))
+ return;
event.reply(":white_check_mark:").setEphemeral(true).queue();
- Thread thread = new Thread(() -> {
- Message message = event.getChannel().sendMessage(time.getSeconds() + "秒的計時開始," + TimeFormat.TIME_LONG.after(time) + "後結束")
- .setComponents(ActionRow.of(Button.danger("terminateTimer", "強制結束計時"))).complete();
- try {
- if (time.getSeconds() > 30) {
- Thread.sleep(time.toMillis() - 30000);
- try {
- Audio.play(Audio.Resource.TIMER_30S_REMAINING, Objects.requireNonNull(Objects.requireNonNull(Objects.requireNonNull(
- event.getGuild()).getMember(event.getUser())).getVoiceState()).getChannel());
- } catch (NullPointerException ignored) {
- }
- Thread.sleep(30000);
- } else {
- Thread.sleep(time.toMillis());
- }
- try {
- Audio.play(Audio.Resource.TIMER_ENDED, Objects.requireNonNull(Objects.requireNonNull(Objects.requireNonNull(
- event.getGuild()).getMember(event.getUser())).getVoiceState()).getChannel());
- } catch (NullPointerException ignored) {
- }
- message.editMessage(message.getContentRaw() + " (已結束)").queue();
- message.reply("計時結束").queue();
- } catch (InterruptedException e) {
- message.reply("計時被終止").queue();
- }
- });
- thread.start();
- timers.put(event.getChannel().getIdLong(), thread);
- }
- @Subcommand(description = "開始自動發言流程")
- public void auto(SlashCommandInteractionEvent event) {
- event.deferReply(false).queue();
- if (!CmdUtils.isAdmin(event)) return;
- Session session = CmdUtils.getSession(event);
- if (session == null) return;
- if (speechSessions.containsKey(Objects.requireNonNull(event.getGuild()).getIdLong())) {
- event.getHook().editOriginal("已經在發言流程中,請先終止上一個流程再繼續").queue();
+ @NotNull
+ var member = Objects.requireNonNull(event.getMember());
+ @NotNull
+ var voiceState = Objects.requireNonNull(member.getVoiceState());
+ if (voiceState.getChannel() == null) {
return;
}
- speechSessions.put(event.getGuild().getIdLong(), SpeechSession.builder()
- .guildId(event.getGuild().getIdLong())
- .channelId(event.getChannel().getIdLong())
- .session(session)
- .build());
-
- for (Session.Player player : session.getPlayers().values()) {
- assert player.getUserId() != null;
- try {
- if (session.isMuteAfterSpeech())
- Objects.requireNonNull(Objects.requireNonNull(event.getGuild()).getMemberById(player.getUserId())).mute(true).queue();
- } catch (IllegalStateException ignored) {
- }
- if (player.isPolice()) {
- event.getHook().editOriginalEmbeds(new EmbedBuilder().setTitle("警長請選擇發言順序")
- .setDescription("警長尚未選擇順序")
- .setColor(MsgUtils.getRandomColor()).build())
- .setComponents(ActionRow.of(StringSelectMenu.create("selectOrder")
- .addOption(Order.UP.toString(), "up", Order.UP.toEmoji())
- .addOption(Order.DOWN.toString(), "down", Order.DOWN.toEmoji())
- .setPlaceholder("請警長按此選擇發言順序").build()
- ), ActionRow.of(Button.success("confirmOrder", "確認選取"))).queue();
- return;
- }
- }
- List shuffledPlayers = new LinkedList<>(session.getPlayers().values());
- Collections.shuffle(shuffledPlayers);
- Order order = Order.getRandomOrder();
- changeOrder(event.getGuild(), order, session.getPlayers().values(), shuffledPlayers.getFirst());
- event.getHook().editOriginalEmbeds(new EmbedBuilder().setTitle("找不到警長,自動抽籤發言順序")
- .setDescription("抽到的順序: 玩家" + shuffledPlayers.getFirst().getId() + order.toString())
- .setColor(MsgUtils.getRandomColor()).build()).queue();
- speechSessions.get(event.getGuild().getIdLong()).next();
-
- for (TextChannel channel : event.getGuild().getTextChannels()) {
- channel.sendMessage("⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯我是白天分隔線⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯").queue();
- }
+ WerewolfApplication.speechService.startTimer(
+ event.getGuild().getIdLong(),
+ event.getChannel().getIdLong(),
+ voiceState.getChannel().getIdLong(),
+ (int) time.getSeconds());
}
@Subcommand(description = "強制終止自動發言流程 (不是終止目前使用者發言) (通常用在狼自爆的時候)")
public void interrupt(SlashCommandInteractionEvent event) {
event.deferReply().queue();
- if (!CmdUtils.isAdmin(event)) return;
- if (!speechSessions.containsKey(Objects.requireNonNull(event.getGuild()).getIdLong())) {
- event.getHook().editOriginal("不在發言流程中").queue();
- } else {
- speechSessions.get(event.getGuild().getIdLong()).getOrder().clear();
- speechSessions.get(Objects.requireNonNull(event.getGuild()).getIdLong()).interrupt();
- event.getHook().editOriginal(":white_check_mark:").queue();
- }
+ if (!CmdUtils.isAdmin(event))
+ return;
+
+ WerewolfApplication.speechService.interruptSession(event.getGuild().getIdLong());
+ event.getHook().editOriginal(":white_check_mark:").queue();
}
@Subcommand(description = "解除所有人的靜音")
public void unmute_all(SlashCommandInteractionEvent event) {
- if (!CmdUtils.isAdmin(event)) return;
- for (Member member : Objects.requireNonNull(event.getGuild()).getMembers()) {
- try {
- member.mute(false).queue();
- } catch (IllegalStateException ignored) {
- }
- }
+ if (!CmdUtils.isAdmin(event))
+ return;
+ WerewolfApplication.speechService.setAllMute(event.getGuild().getIdLong(), false);
event.reply(":white_check_mark:").queue();
}
@Subcommand(description = "靜音所有人")
public void mute_all(SlashCommandInteractionEvent event) {
- if (!CmdUtils.isAdmin(event)) return;
- for (Member member : Objects.requireNonNull(event.getGuild()).getMembers()) {
- try {
- if (member.getPermissions().contains(Permission.ADMINISTRATOR)) continue;
- member.mute(true).queue();
- } catch (IllegalStateException ignored) {
- }
- }
+ if (!CmdUtils.isAdmin(event))
+ return;
+ WerewolfApplication.speechService.setAllMute(event.getGuild().getIdLong(), true);
event.reply(":white_check_mark:").queue();
}
- public enum Order {
- UP, DOWN;
-
- public static Order getRandomOrder() {
- return Order.values()[(int) (Math.random() * Order.values().length)];
- }
-
- public String toString() {
- if (UP.equals(this)) return "往上";
- else return "往下";
- }
+ // Interactions
+ @dev.robothanzo.jda.interactions.annotations.select.StringSelectMenu
+ public void selectOrder(StringSelectInteractionEvent event) {
+ WerewolfApplication.speechService.handleOrderSelection(event);
+ }
- public Emoji toEmoji() {
- if (UP.equals(this)) return Emoji.fromUnicode("U+2b06");
- else return Emoji.fromUnicode("U+2b07");
- }
+ @dev.robothanzo.jda.interactions.annotations.Button
+ public void confirmOrder(ButtonInteractionEvent event) {
+ WerewolfApplication.speechService.confirmOrder(event);
}
- @Data
- @Builder
- public static class SpeechSession {
- private long guildId;
- private long channelId;
- private Session session;
- @Builder.Default
- private List interruptVotes = new LinkedList<>();
- @Builder.Default
- private List order = new LinkedList<>();
- @Nullable
- private Thread speakingThread;
- @Nullable
- private Long lastSpeaker;
- @Nullable
- private Runnable finishedCallback;
+ @dev.robothanzo.jda.interactions.annotations.Button
+ public void skipSpeech(ButtonInteractionEvent event) {
+ WerewolfApplication.speechService.skipSpeech(event);
+ }
- public void interrupt() {
- if (speakingThread != null) {
- speakingThread.interrupt();
- }
- speechSessions.remove(guildId);
- }
+ @dev.robothanzo.jda.interactions.annotations.Button
+ public void interruptSpeech(ButtonInteractionEvent event) {
+ WerewolfApplication.speechService.interruptSpeech(event);
+ }
- public void next() {
- interruptVotes.clear();
- if (speakingThread != null) {
- speakingThread.interrupt();
- }
- Guild guild = Objects.requireNonNull(WerewolfHelper.jda.getGuildById(guildId));
- if (lastSpeaker != null) {
- Member member = guild.getMemberById(lastSpeaker);
- if (member == null) {
- guild.retrieveMemberById(lastSpeaker).queue(m -> {
- try {
- if (session.isMuteAfterSpeech())
- m.mute(true).queue();
- } catch (IllegalStateException ignored) {
- }
- });
- } else {
- try {
- if (session.isMuteAfterSpeech())
- member.mute(true).queue();
- } catch (IllegalStateException ignored) {
- }
- }
- }
- if (order.isEmpty()) {
- Objects.requireNonNull(guild.getTextChannelById(channelId)).sendMessage("發言流程結束").queue();
- interrupt();
- if (finishedCallback != null) finishedCallback.run();
- return;
+ @dev.robothanzo.jda.interactions.annotations.Button
+ public void terminateTimer(ButtonInteractionEvent event) {
+ event.deferReply(true).queue();
+ if (CmdUtils.isAdmin(event)) {
+ try {
+ WerewolfApplication.speechService.stopTimer(event.getChannel().getIdLong());
+ event.getHook().editOriginal(":white_check_mark:").queue();
+ } catch (Exception e) {
+ event.getHook().editOriginal(":x:").queue();
}
- final Session.Player player = order.getFirst();
- speakingThread = new Thread(() -> {
- lastSpeaker = player.getUserId();
- assert lastSpeaker != null;
- int time = player.isPolice() ? 210 : 180;
- try {
- Objects.requireNonNull(guild.getMemberById(lastSpeaker)).mute(false).queue();
- } catch (IllegalStateException ignored) {
- }
- Message message = Objects.requireNonNull(guild.getTextChannelById(channelId))
- .sendMessage("<@!" + player.getUserId() + "> 你有" + time + "秒可以發言\n")
- .setComponents(ActionRow.of(
- Button.danger("skipSpeech", "跳過 (發言者按)").withEmoji(Emoji.fromUnicode("U+23ed")),
- Button.danger("interruptSpeech", "下台 (玩家或法官按)").withEmoji(Emoji.fromUnicode("U+1f5d1"))
- )).complete();
- AudioChannel channel = guild.getVoiceChannelById(session.getCourtVoiceChannelId());
- try {
- Thread.sleep((time - 30) * 1000);
- Audio.play(Audio.Resource.TIMER_30S_REMAINING, channel);
- Thread.sleep(35000); // 5 extra seconds to allocate space for latency and notification sounds
- Audio.play(Audio.Resource.TIMER_ENDED, channel);
- message.reply("計時結束" + (order.isEmpty() ? ",下面一位" : "")).queue();
- next();
- } catch (InterruptedException ignored) {
- Audio.play(Audio.Resource.TIMER_ENDED, channel);
-// message.reply("計時被終止" + (order.size() == 0 ? ",下面一位" : "")).queue();
- } catch (NullPointerException ignored) {
- Audio.play(Audio.Resource.TIMER_ENDED, channel);
- message.reply("發言者已離開語音或機器人出錯" + (order.isEmpty() ? ",下面一位" : "")).queue();
- next();
- }
- });
- speakingThread.start();
- order.removeFirst();
+ } else {
+ event.getHook().editOriginal(":x:").queue();
}
}
}
diff --git a/src/main/java/dev/robothanzo/werewolf/config/SecurityConfig.java b/src/main/java/dev/robothanzo/werewolf/config/SecurityConfig.java
new file mode 100644
index 0000000..2f66bb3
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/config/SecurityConfig.java
@@ -0,0 +1,74 @@
+package dev.robothanzo.werewolf.config;
+
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import java.util.Arrays;
+import java.util.List;
+
+@Configuration
+@EnableWebSecurity
+@EnableMethodSecurity
+public class SecurityConfig {
+
+ @Bean
+ public UserDetailsService userDetailsService() {
+ return new InMemoryUserDetailsManager();
+ }
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(AbstractHttpConfigurer::disable)
+ .cors(cors -> cors.configurationSource(corsConfigurationSource()))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/api/auth/**").permitAll()
+ .requestMatchers("/ws/**").permitAll()
+ .requestMatchers("/actuator/**").permitAll()
+ .requestMatchers("/error").permitAll()
+ .anyRequest().authenticated())
+ .exceptionHandling(ex -> ex
+ .authenticationEntryPoint((request, response, authException) -> {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setContentType("application/json");
+ response.getWriter().write("{\"success\":false,\"error\":\"Unauthorized\"}");
+ })
+ .accessDeniedHandler((request, response, accessDeniedException) -> {
+ response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ response.setContentType("application/json");
+ response.getWriter().write("{\"success\":false,\"error\":\"Forbidden\"}");
+ }));
+
+ http.addFilterBefore(new UserSessionFilter(), UsernamePasswordAuthenticationFilter.class);
+
+ return http.build();
+ }
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+ configuration.setAllowedOrigins(Arrays.asList(
+ "http://localhost:5173",
+ "https://wolf.robothanzo.dev",
+ "http://wolf.robothanzo.dev"));
+ configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
+ configuration.setAllowedHeaders(List.of("*"));
+ configuration.setAllowCredentials(true);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/config/SessionConfig.java b/src/main/java/dev/robothanzo/werewolf/config/SessionConfig.java
new file mode 100644
index 0000000..632b78d
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/config/SessionConfig.java
@@ -0,0 +1,9 @@
+package dev.robothanzo.werewolf.config;
+
+import org.mongodb.spring.session.config.annotation.web.http.EnableMongoHttpSession;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@EnableMongoHttpSession(collectionName = "http_sessions")
+public class SessionConfig {
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/config/UserSessionFilter.java b/src/main/java/dev/robothanzo/werewolf/config/UserSessionFilter.java
new file mode 100644
index 0000000..6e9a988
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/config/UserSessionFilter.java
@@ -0,0 +1,46 @@
+package dev.robothanzo.werewolf.config;
+
+import dev.robothanzo.werewolf.database.documents.AuthSession;
+import dev.robothanzo.werewolf.database.documents.UserRole;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+public class UserSessionFilter extends OncePerRequestFilter {
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+
+ HttpSession session = request.getSession(false);
+ if (session != null) {
+ Object userObj = session.getAttribute("user");
+ if (userObj instanceof AuthSession user) {
+ UserRole role = user.getRole();
+
+ java.util.List authorities = new java.util.ArrayList<>();
+ authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
+ if (role != null) {
+ authorities.add(new SimpleGrantedAuthority("ROLE_" + role.name()));
+ }
+
+ UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
+ user,
+ null,
+ authorities);
+
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ }
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/config/WebSocketConfig.java b/src/main/java/dev/robothanzo/werewolf/config/WebSocketConfig.java
new file mode 100644
index 0000000..6223483
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/config/WebSocketConfig.java
@@ -0,0 +1,25 @@
+package dev.robothanzo.werewolf.config;
+
+import dev.robothanzo.werewolf.security.GlobalWebSocketHandler;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.socket.config.annotation.EnableWebSocket;
+import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
+import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
+
+@Configuration
+@EnableWebSocket
+public class WebSocketConfig implements WebSocketConfigurer {
+
+ private final GlobalWebSocketHandler globalWebSocketHandler;
+
+ public WebSocketConfig(GlobalWebSocketHandler globalWebSocketHandler) {
+ this.globalWebSocketHandler = globalWebSocketHandler;
+ }
+
+ @Override
+ public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
+ registry.addHandler(globalWebSocketHandler, "/ws")
+ .addInterceptors(new org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor())
+ .setAllowedOrigins("*");
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/controller/AuthController.java b/src/main/java/dev/robothanzo/werewolf/controller/AuthController.java
new file mode 100644
index 0000000..91336eb
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/controller/AuthController.java
@@ -0,0 +1,149 @@
+package dev.robothanzo.werewolf.controller;
+
+import dev.robothanzo.werewolf.database.documents.AuthSession;
+import dev.robothanzo.werewolf.database.documents.UserRole;
+import dev.robothanzo.werewolf.service.DiscordService;
+import io.mokulu.discord.oauth.DiscordAPI;
+import io.mokulu.discord.oauth.DiscordOAuth;
+import io.mokulu.discord.oauth.model.TokensResponse;
+import io.mokulu.discord.oauth.model.User;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.io.IOException;
+import java.util.Map;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/auth")
+@RequiredArgsConstructor
+public class AuthController {
+
+ private static final String CLIENT_ID = System.getenv().getOrDefault("DISCORD_CLIENT_ID", "");
+ private static final String CLIENT_SECRET = System.getenv().getOrDefault("DISCORD_CLIENT_SECRET", "");
+ private static final String REDIRECT_URI = System.getenv().getOrDefault("DISCORD_REDIRECT_URI",
+ "http://localhost:5173/auth/callback");
+
+ private final DiscordOAuth discordOAuth = new DiscordOAuth(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI,
+ new String[]{"identify", "guilds", "guilds.members.read"});
+ private final DiscordService discordService;
+
+ @GetMapping("/login")
+ public void login(@RequestParam(name = "guild_id", required = false) String guildId, HttpServletResponse response)
+ throws IOException {
+ String state = guildId != null ? guildId : "no_guild";
+ response.sendRedirect(discordOAuth.getAuthorizationURL(state));
+ }
+
+ @GetMapping("/callback")
+ public void callback(@RequestParam String code, @RequestParam String state, HttpSession session,
+ HttpServletResponse response) throws IOException {
+ try {
+ TokensResponse tokenResponse = discordOAuth.getTokens(code);
+ DiscordAPI discordAPI = new DiscordAPI(tokenResponse.getAccessToken());
+ User user = discordAPI.fetchUser();
+
+ // Store user in Session
+ AuthSession authSession = AuthSession.builder()
+ .userId(user.getId())
+ .username(user.getUsername())
+ .discriminator(user.getDiscriminator())
+ .avatar(user.getAvatar())
+ .build();
+
+ session.setAttribute("user", authSession);
+
+ if (!"no_guild".equals(state)) {
+ try {
+ // Validate it's a number
+ long gid = Long.parseLong(state);
+ authSession.setGuildId(state); // Store as String
+
+ // Attempt to pre-calculate role
+ net.dv8tion.jda.api.entities.Member member = discordService.getMember(gid, user.getId());
+ if (member != null && (member.hasPermission(net.dv8tion.jda.api.Permission.ADMINISTRATOR)
+ || member.hasPermission(net.dv8tion.jda.api.Permission.MANAGE_SERVER))) {
+ authSession.setRole(UserRole.JUDGE);
+ } else {
+ authSession.setRole(UserRole.SPECTATOR);
+ }
+ } catch (Exception e) {
+ log.warn("Failed to set initial guild info: {}", state, e);
+ authSession.setRole(UserRole.PENDING);
+ }
+
+ session.setAttribute("user", authSession);
+ response.sendRedirect(
+ System.getenv().getOrDefault("DASHBOARD_URL", "http://localhost:5173") + "/server/" + state);
+ } else {
+ authSession.setRole(UserRole.PENDING);
+ session.setAttribute("user", authSession);
+ response.sendRedirect(System.getenv().getOrDefault("DASHBOARD_URL", "http://localhost:5173") + "/");
+ }
+ } catch (Exception e) {
+ log.error("Auth callback failed", e);
+ response.sendError(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Auth failed");
+ }
+ }
+
+ @PostMapping("/select-guild/{guildId}")
+ public ResponseEntity> selectGuild(@PathVariable String guildId, HttpSession session) {
+ AuthSession user = (AuthSession) session.getAttribute("user");
+ if (user == null) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+
+ try {
+ long gid = Long.parseLong(guildId);
+ net.dv8tion.jda.api.entities.Guild guild = discordService.getGuild(gid);
+ if (guild == null) {
+ return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Guild not found"));
+ }
+
+ net.dv8tion.jda.api.entities.Member member = discordService.getMember(gid, user.getUserId());
+ if (member == null) {
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body(Map.of("success", false, "error", "Not a member"));
+ }
+
+ // Determine role
+ UserRole role = UserRole.SPECTATOR; // Default
+ if (member.hasPermission(net.dv8tion.jda.api.Permission.ADMINISTRATOR)
+ || member.hasPermission(net.dv8tion.jda.api.Permission.MANAGE_SERVER)) {
+ role = UserRole.JUDGE;
+ }
+
+ user.setGuildId(guildId); // Store as String
+ user.setRole(role);
+ session.setAttribute("user", user);
+
+ return ResponseEntity.ok(Map.of("success", true, "user", user));
+ } catch (NumberFormatException e) {
+ return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Invalid guild ID"));
+ } catch (Exception e) {
+ log.error("Failed to select guild", e);
+ return ResponseEntity.internalServerError().body(Map.of("success", false, "error", "Internal error"));
+ }
+ }
+
+ @GetMapping("/me")
+ public ResponseEntity> me(HttpSession session) {
+ AuthSession user = (AuthSession) session.getAttribute("user");
+ if (user == null) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
+ .body(Map.of("success", false, "error", "Not authenticated"));
+ }
+ return ResponseEntity.ok(Map.of("success", true, "user", user));
+ }
+
+ @PostMapping("/logout")
+ public ResponseEntity> logout(HttpSession session) {
+ session.invalidate();
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/controller/GameController.java b/src/main/java/dev/robothanzo/werewolf/controller/GameController.java
new file mode 100644
index 0000000..ab6c5da
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/controller/GameController.java
@@ -0,0 +1,211 @@
+package dev.robothanzo.werewolf.controller;
+
+import dev.robothanzo.werewolf.security.annotations.CanManageGuild;
+import dev.robothanzo.werewolf.security.annotations.CanViewGuild;
+import dev.robothanzo.werewolf.service.GameActionService;
+import dev.robothanzo.werewolf.service.GameSessionService;
+import dev.robothanzo.werewolf.service.PlayerService;
+import dev.robothanzo.werewolf.service.RoleService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/sessions/{guildId}")
+@RequiredArgsConstructor
+public class GameController {
+
+ private final PlayerService playerService;
+ private final RoleService roleService;
+ private final GameActionService gameActionService;
+ private final GameSessionService gameSessionService;
+
+ // --- Players ---
+ @GetMapping("/players")
+ @CanViewGuild
+ public ResponseEntity> getPlayers(@PathVariable long guildId) {
+ return ResponseEntity.ok(Map.of("success", true, "data", playerService.getPlayersJSON(guildId)));
+ }
+
+ @PostMapping("/players/assign")
+ @CanManageGuild
+ public ResponseEntity> assignRoles(@PathVariable long guildId) {
+ try {
+ roleService.assignRoles(guildId,
+ msg -> gameActionService.broadcastProgress(guildId, msg, null),
+ pct -> gameActionService.broadcastProgress(guildId, null, pct));
+ return ResponseEntity.ok(Map.of("success", true, "message", "Roles assigned"));
+ } catch (Exception e) {
+ return ResponseEntity.internalServerError().body(Map.of("success", false, "error", e.getMessage()));
+ }
+ }
+
+ @PostMapping("/players/{playerId}/roles")
+ @CanManageGuild
+ public ResponseEntity> updatePlayerRoles(@PathVariable long guildId, @PathVariable String playerId,
+ @RequestBody List roles) {
+ try {
+ playerService.updatePlayerRoles(guildId, playerId, roles);
+ return ResponseEntity.ok(Map.of("success", true));
+ } catch (Exception e) {
+ return ResponseEntity.internalServerError().body(Map.of("success", false, "error", e.getMessage()));
+ }
+ }
+
+ @PostMapping("/players/{userId}/role")
+ @CanManageGuild
+ public ResponseEntity> updateUserRole(@PathVariable long guildId, @PathVariable long userId,
+ @RequestBody Map body) {
+ try {
+ dev.robothanzo.werewolf.database.documents.UserRole role = dev.robothanzo.werewolf.database.documents.UserRole
+ .fromString(body.get("role"));
+ gameSessionService.updateUserRole(guildId, userId, role);
+ return ResponseEntity.ok(Map.of("success", true));
+ } catch (Exception e) {
+ return ResponseEntity.internalServerError().body(Map.of("success", false, "error", e.getMessage()));
+ }
+ }
+
+ @PostMapping("/players/{playerId}/switch-role-order")
+ @CanManageGuild
+ public ResponseEntity> switchRoleOrder(@PathVariable long guildId, @PathVariable String playerId) {
+ try {
+ playerService.switchRoleOrder(guildId, playerId);
+ return ResponseEntity.ok(Map.of("success", true));
+ } catch (Exception e) {
+ return ResponseEntity.internalServerError().body(Map.of("success", false, "error", e.getMessage()));
+ }
+ }
+
+ @PostMapping("/players/{playerId}/role-lock")
+ @CanManageGuild
+ public ResponseEntity> setRoleLock(@PathVariable long guildId, @PathVariable String playerId,
+ @RequestParam boolean locked) {
+ try {
+ playerService.setRolePositionLock(guildId, playerId, locked);
+ return ResponseEntity.ok(Map.of("success", true));
+ } catch (Exception e) {
+ return ResponseEntity.internalServerError().body(Map.of("success", false, "error", e.getMessage()));
+ }
+ }
+
+ // --- Actions ---
+ @PostMapping("/players/{userId}/died")
+ @CanManageGuild
+ public ResponseEntity> markDead(@PathVariable long guildId, @PathVariable long userId,
+ @RequestParam(defaultValue = "false") boolean lastWords) {
+ gameActionService.markPlayerDead(guildId, userId, lastWords);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ @PostMapping("/players/{userId}/revive")
+ @CanManageGuild
+ public ResponseEntity> revive(@PathVariable long guildId, @PathVariable long userId) {
+ gameActionService.revivePlayer(guildId, userId);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ @PostMapping("/players/{userId}/revive-role")
+ @CanManageGuild
+ public ResponseEntity> reviveRole(@PathVariable long guildId, @PathVariable long userId,
+ @RequestParam String role) {
+ gameActionService.reviveRole(guildId, userId, role);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ @PostMapping("/players/{userId}/police")
+ @CanManageGuild
+ public ResponseEntity> setPolice(@PathVariable long guildId, @PathVariable long userId) {
+ gameActionService.setPolice(guildId, userId);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ // --- Roles ---
+ @GetMapping("/roles")
+ @CanViewGuild
+ public ResponseEntity> getRoles(@PathVariable long guildId) {
+ return ResponseEntity.ok(Map.of("success", true, "data", roleService.getRoles(guildId)));
+ }
+
+ @PostMapping("/roles/add")
+ @CanManageGuild
+ public ResponseEntity> addRole(@PathVariable long guildId, @RequestParam String role,
+ @RequestParam(defaultValue = "1") int amount) {
+ roleService.addRole(guildId, role, amount);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ @DeleteMapping("/roles/{role}")
+ @CanManageGuild
+ public ResponseEntity> removeRole(@PathVariable long guildId, @PathVariable String role,
+ @RequestParam(defaultValue = "1") int amount) {
+ roleService.removeRole(guildId, role, amount);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ // --- Guild ---
+ @GetMapping("/members")
+ @CanViewGuild
+ public ResponseEntity> getMembers(@PathVariable long guildId) {
+ try {
+ return ResponseEntity.ok(Map.of("success", true, "data", gameSessionService.getGuildMembers(guildId)));
+ } catch (Exception e) {
+ return ResponseEntity.internalServerError().body(Map.of("success", false, "error", e.getMessage()));
+ }
+ }
+
+ @PutMapping("/settings")
+ @CanManageGuild
+ public ResponseEntity> updateSettings(@PathVariable long guildId, @RequestBody Map body) {
+ try {
+ gameSessionService.updateSettings(guildId, body);
+ return ResponseEntity.ok(Map.of("success", true));
+ } catch (Exception e) {
+ return ResponseEntity.internalServerError().body(Map.of("success", false, "error", e.getMessage()));
+ }
+ }
+
+ @PostMapping("/player-count")
+ @CanManageGuild
+ public ResponseEntity> setPlayerCount(@PathVariable long guildId, @RequestBody Map body) {
+ try {
+ playerService.setPlayerCount(guildId, body.get("count"),
+ msg -> gameActionService.broadcastProgress(guildId, msg, null),
+ pct -> gameActionService.broadcastProgress(guildId, null, pct));
+ return ResponseEntity.ok(Map.of("success", true));
+ } catch (Exception e) {
+ return ResponseEntity.internalServerError().body(Map.of("success", false, "error", e.getMessage()));
+ }
+ }
+
+ @PostMapping("/start")
+ @CanManageGuild
+ public ResponseEntity> startGame(@PathVariable long guildId) {
+ try {
+ var session = gameSessionService.getSession(guildId)
+ .orElseThrow(() -> new Exception("Session not found"));
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.GAME_STARTED, "遊戲正式開始!", null);
+ gameSessionService.saveSession(session);
+ gameSessionService.broadcastSessionUpdate(session);
+ return ResponseEntity.ok(Map.of("success", true));
+ } catch (Exception e) {
+ return ResponseEntity.internalServerError().body(Map.of("success", false, "error", e.getMessage()));
+ }
+ }
+
+ @PostMapping("/reset")
+ @CanManageGuild
+ public ResponseEntity> resetGame(@PathVariable long guildId) {
+ try {
+ gameActionService.resetGame(guildId,
+ msg -> gameActionService.broadcastProgress(guildId, msg, null),
+ pct -> gameActionService.broadcastProgress(guildId, null, pct));
+ return ResponseEntity.ok(Map.of("success", true));
+ } catch (Exception e) {
+ return ResponseEntity.internalServerError().body(Map.of("success", false, "error", e.getMessage()));
+ }
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/controller/SessionController.java b/src/main/java/dev/robothanzo/werewolf/controller/SessionController.java
new file mode 100644
index 0000000..a6b924f
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/controller/SessionController.java
@@ -0,0 +1,52 @@
+package dev.robothanzo.werewolf.controller;
+
+import dev.robothanzo.werewolf.security.annotations.CanViewGuild;
+import dev.robothanzo.werewolf.service.GameSessionService;
+import dev.robothanzo.werewolf.utils.IdentityUtils;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/sessions")
+@RequiredArgsConstructor
+public class SessionController {
+
+ private final GameSessionService gameSessionService;
+ private final dev.robothanzo.werewolf.service.DiscordService discordService;
+ private final IdentityUtils identityUtils;
+
+ @GetMapping
+ public ResponseEntity> getAllSessions() {
+ var userOpt = identityUtils.getCurrentUser();
+ if (userOpt.isEmpty()) {
+ return ResponseEntity.status(org.springframework.http.HttpStatus.UNAUTHORIZED).build();
+ }
+ dev.robothanzo.werewolf.database.documents.AuthSession user = userOpt.get();
+
+ List> data = gameSessionService.getAllSessions().stream()
+ .filter(session -> {
+ // Check if user is in guild
+ return discordService.getMember(session.getGuildId(), user.getUserId()) != null;
+ })
+ .map(gameSessionService::sessionToSummaryJSON)
+ .collect(Collectors.toList());
+ return ResponseEntity.ok(Map.of("success", true, "data", data));
+ }
+
+ @GetMapping("/{guildId}")
+ @CanViewGuild
+ public ResponseEntity> getSession(@PathVariable long guildId) {
+ return gameSessionService.getSession(guildId)
+ .map(session -> ResponseEntity
+ .ok(Map.of("success", true, "data", gameSessionService.sessionToJSON(session))))
+ .orElse(ResponseEntity.notFound().build());
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/controller/SpeechController.java b/src/main/java/dev/robothanzo/werewolf/controller/SpeechController.java
new file mode 100644
index 0000000..3b5977a
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/controller/SpeechController.java
@@ -0,0 +1,133 @@
+package dev.robothanzo.werewolf.controller;
+
+import dev.robothanzo.werewolf.WerewolfApplication;
+import dev.robothanzo.werewolf.database.documents.Session;
+import dev.robothanzo.werewolf.security.annotations.CanManageGuild;
+import dev.robothanzo.werewolf.service.DiscordService;
+import dev.robothanzo.werewolf.service.GameSessionService;
+import lombok.RequiredArgsConstructor;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+import java.util.Optional;
+
+@RestController
+@RequestMapping("/api/sessions/{guildId}/speech")
+@RequiredArgsConstructor
+public class SpeechController {
+
+ private final GameSessionService gameSessionService;
+ private final DiscordService discordService;
+ private final dev.robothanzo.werewolf.service.SpeechService speechService;
+
+ @PostMapping("/auto")
+ @CanManageGuild
+ public ResponseEntity> startAutoSpeech(@PathVariable long guildId) {
+ Optional sessionOpt = gameSessionService.getSession(guildId);
+ if (sessionOpt.isEmpty())
+ return ResponseEntity.notFound().build();
+ Session session = sessionOpt.get();
+
+ Guild guild = discordService.getGuild(guildId);
+ TextChannel channel = guild.getTextChannelById(session.getCourtTextChannelId());
+
+ if (channel != null) {
+ speechService.startAutoSpeechFlow(guildId, channel.getIdLong());
+ }
+ gameSessionService.broadcastUpdate(guildId);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ @PostMapping("/skip")
+ @CanManageGuild
+ public ResponseEntity> skipSpeech(@PathVariable long guildId) {
+ speechService.skipToNext(guildId);
+ gameSessionService.broadcastUpdate(guildId);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ @PostMapping("/interrupt")
+ @CanManageGuild
+ public ResponseEntity> interruptSpeech(@PathVariable long guildId) {
+ speechService.interruptSession(guildId);
+ gameSessionService.broadcastUpdate(guildId);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ @PostMapping("/police-enroll")
+ @CanManageGuild
+ public ResponseEntity> startPoliceEnroll(@PathVariable long guildId) {
+ Optional sessionOpt = gameSessionService.getSession(guildId);
+ if (sessionOpt.isEmpty())
+ return ResponseEntity.notFound().build();
+ Session session = sessionOpt.get();
+
+ Guild guild = discordService.getGuild(guildId);
+ TextChannel channel = guild.getTextChannelById(session.getCourtTextChannelId());
+
+ WerewolfApplication.policeService.startEnrollment(session, channel, null);
+ gameSessionService.broadcastUpdate(guildId);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ @PostMapping("/order")
+ @CanManageGuild
+ public ResponseEntity> setSpeechOrder(@PathVariable long guildId, @RequestBody Map body) {
+ String direction = body.get("direction");
+ dev.robothanzo.werewolf.model.SpeechOrder order = dev.robothanzo.werewolf.model.SpeechOrder
+ .valueOf(direction.toUpperCase());
+
+ speechService.setSpeechOrder(guildId, order);
+ speechService.confirmSpeechOrder(guildId);
+
+ gameSessionService.broadcastUpdate(guildId);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ @PostMapping("/confirm")
+ @CanManageGuild
+ public ResponseEntity> confirmSpeech(@PathVariable long guildId) {
+ speechService.confirmSpeechOrder(guildId);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ @PostMapping("/manual-start")
+ @CanManageGuild
+ public ResponseEntity> manualStartTimer(@PathVariable long guildId, @RequestBody Map body) {
+ int duration = body.get("duration");
+
+ Optional sessionOpt = gameSessionService.getSession(guildId);
+ if (sessionOpt.isEmpty())
+ return ResponseEntity.notFound().build();
+ Session session = sessionOpt.get();
+
+ Guild guild = discordService.getGuild(guildId);
+ TextChannel channel = guild.getTextChannelById(session.getCourtTextChannelId());
+ var voiceChannel = guild.getVoiceChannelById(session.getCourtVoiceChannelId());
+
+ if (channel != null) {
+ speechService.startTimer(guildId, channel.getIdLong(), voiceChannel != null ? voiceChannel.getIdLong() : 0,
+ duration);
+ }
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ @PostMapping("/mute-all")
+ @CanManageGuild
+ public ResponseEntity> muteAll(@PathVariable long guildId) {
+ speechService.setAllMute(guildId, true);
+ gameSessionService.broadcastUpdate(guildId);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ @PostMapping("/unmute-all")
+ @CanManageGuild
+ public ResponseEntity> unmuteAll(@PathVariable long guildId) {
+ speechService.setAllMute(guildId, false);
+ gameSessionService.broadcastUpdate(guildId);
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/database/Database.java b/src/main/java/dev/robothanzo/werewolf/database/Database.java
index 4ec3f2b..f06cdc3 100644
--- a/src/main/java/dev/robothanzo/werewolf/database/Database.java
+++ b/src/main/java/dev/robothanzo/werewolf/database/Database.java
@@ -21,8 +21,43 @@ public static void initDatabase(@Nullable CodecRegistry... codecRegistry) {
ConnectionString connString = new ConnectionString(
System.getenv().getOrDefault("DATABASE", "mongodb://localhost:27017")
);
- CodecRegistry pojoCodecRegistry = fromRegistries(MongoClientSettings.getDefaultCodecRegistry(),
- fromProviders(PojoCodecProvider.builder().automatic(true).build()));
+
+ // Configure POJO codec to use fields directly and ignore getters without fields
+ PojoCodecProvider pojoProvider = PojoCodecProvider.builder()
+ .automatic(true)
+ .conventions(java.util.Arrays.asList(
+ org.bson.codecs.pojo.Conventions.ANNOTATION_CONVENTION,
+ org.bson.codecs.pojo.Conventions.CLASS_AND_PROPERTY_CONVENTION,
+ org.bson.codecs.pojo.Conventions.SET_PRIVATE_FIELDS_CONVENTION,
+ builder -> {
+ // Custom convention: Remove properties discovered via getters/setters that don't have a backing field
+ java.util.List toRemove = new java.util.ArrayList<>();
+ for (org.bson.codecs.pojo.PropertyModelBuilder> propertyBuilder : builder.getPropertyModelBuilders()) {
+ boolean hasField = false;
+ Class> current = builder.getType();
+ while (current != null && current != Object.class) {
+ try {
+ current.getDeclaredField(propertyBuilder.getName());
+ hasField = true;
+ break;
+ } catch (NoSuchFieldException ignored) {
+ current = current.getSuperclass();
+ }
+ }
+ if (!hasField) {
+ toRemove.add(propertyBuilder.getName());
+ }
+ }
+ toRemove.forEach(builder::removeProperty);
+ }
+ ))
+ .build();
+
+ CodecRegistry pojoCodecRegistry = fromRegistries(
+ MongoClientSettings.getDefaultCodecRegistry(),
+ fromProviders(pojoProvider)
+ );
+
if (codecRegistry != null) {
for (CodecRegistry codec : codecRegistry) {
pojoCodecRegistry = fromRegistries(pojoCodecRegistry, codec);
diff --git a/src/main/java/dev/robothanzo/werewolf/database/documents/AuthSession.java b/src/main/java/dev/robothanzo/werewolf/database/documents/AuthSession.java
new file mode 100644
index 0000000..923efb9
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/database/documents/AuthSession.java
@@ -0,0 +1,57 @@
+package dev.robothanzo.werewolf.database.documents;
+
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.model.IndexOptions;
+import com.mongodb.client.model.Indexes;
+import dev.robothanzo.werewolf.database.Database;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AuthSession implements Serializable {
+ private static final long serialVersionUID = 1L;
+ private String sessionId;
+ private String userId;
+ private String username;
+ private String discriminator;
+ private String avatar;
+ private String guildId;
+ private UserRole role;
+ private Date createdAt;
+
+ public boolean isJudge() {
+ return role == UserRole.JUDGE;
+ }
+
+ public boolean isSpectator() {
+ return role == UserRole.SPECTATOR;
+ }
+
+ public boolean isPrivileged() {
+ return role != null && role.isPrivileged();
+ }
+
+ public boolean isBlocked() {
+ return role == UserRole.BLOCKED;
+ }
+
+ public boolean isPending() {
+ return role == UserRole.PENDING || role == null;
+ }
+
+ public static MongoCollection fetchCollection() {
+ MongoCollection collection = Database.database.getCollection("auth_sessions", AuthSession.class);
+ // Create TTL index on createdAt if it doesn't exist (30 days)
+ collection.createIndex(Indexes.ascending("createdAt"), new IndexOptions().expireAfter(30L, TimeUnit.DAYS));
+ return collection;
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/database/documents/LogType.java b/src/main/java/dev/robothanzo/werewolf/database/documents/LogType.java
new file mode 100644
index 0000000..b296e6a
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/database/documents/LogType.java
@@ -0,0 +1,66 @@
+package dev.robothanzo.werewolf.database.documents;
+
+public enum LogType {
+ // Player Events
+ PLAYER_DIED,
+ PLAYER_REVIVED,
+ ROLE_ASSIGNED,
+ POLICE_TRANSFERRED,
+ POLICE_DESTROYED,
+ POLICE_FORCED,
+
+ // Speech Events
+ SPEECH_STARTED,
+ SPEECH_ENDED,
+ SPEAKER_CHANGED,
+ SPEECH_SKIPPED,
+ SPEECH_INTERRUPTED,
+ SPEECH_ORDER_SET,
+
+ // Poll Events - Police
+ POLICE_ENROLLMENT_STARTED,
+ POLICE_ENROLLED,
+ POLICE_UNENROLLED,
+ POLICE_VOTING_STARTED,
+ POLICE_ELECTED,
+ POLICE_BADGE_DESTROYED,
+
+ // Poll Events - Expel
+ EXPEL_POLL_STARTED,
+ VOTE_CAST,
+ VOTE_RESULT,
+ PLAYER_EXPELLED,
+
+ // System Events
+ GAME_STARTED,
+ GAME_ENDED,
+ GAME_RESET,
+ COMMAND_EXECUTED,
+
+ // Judge Actions
+ PLAYER_PROMOTED_JUDGE,
+ PLAYER_DEMOTED;
+
+ /**
+ * Get the display category for UI grouping
+ */
+ public String getCategory() {
+ String name = this.name();
+ if (name.startsWith("PLAYER_")) return "player";
+ if (name.startsWith("SPEECH_")) return "speech";
+ if (name.startsWith("POLICE_")) return "police";
+ if (name.startsWith("EXPEL_") || name.startsWith("VOTE_")) return "vote";
+ return "system";
+ }
+
+ /**
+ * Get the severity level for UI styling
+ */
+ public String getSeverity() {
+ return switch (this) {
+ case PLAYER_DIED, PLAYER_EXPELLED, POLICE_BADGE_DESTROYED, SPEECH_INTERRUPTED -> "alert";
+ case POLICE_ELECTED, PLAYER_REVIVED, ROLE_ASSIGNED, SPEECH_STARTED, GAME_STARTED, GAME_RESET -> "action";
+ default -> "info";
+ };
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/database/documents/Session.java b/src/main/java/dev/robothanzo/werewolf/database/documents/Session.java
index e90f755..d2b3af9 100644
--- a/src/main/java/dev/robothanzo/werewolf/database/documents/Session.java
+++ b/src/main/java/dev/robothanzo/werewolf/database/documents/Session.java
@@ -3,16 +3,33 @@
import com.mongodb.client.MongoCollection;
import dev.robothanzo.werewolf.database.Database;
import lombok.*;
+import org.bson.BsonType;
+import org.bson.codecs.pojo.annotations.BsonId;
+import org.bson.codecs.pojo.annotations.BsonRepresentation;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.Indexed;
+import org.springframework.data.mongodb.core.mapping.Document;
+import java.text.DecimalFormat;
+import java.text.NumberFormat;
import java.util.*;
+import static com.mongodb.client.model.Filters.eq;
+
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
+@Document(collection = "sessions")
public class Session {
+ @Id
+ @BsonId
+ @BsonRepresentation(BsonType.OBJECT_ID)
+ private String id;
+
+ @Indexed(unique = true)
private long guildId;
private long courtTextChannelId;
private long courtVoiceChannelId;
@@ -30,6 +47,18 @@ public class Session {
private List roles = new LinkedList<>();
@Builder.Default
private Map players = new HashMap<>();
+ @Builder.Default
+ private List logs = new ArrayList<>();
+
+ public Map fetchAlivePlayers() {
+ Map alivePlayers = new HashMap<>();
+ for (Map.Entry entry : players.entrySet()) {
+ if (entry.getValue().isAlive()) {
+ alivePlayers.put(entry.getKey(), entry.getValue());
+ }
+ }
+ return alivePlayers;
+ }
public static MongoCollection fetchCollection() {
return Database.database.getCollection("sessions", Session.class);
@@ -59,6 +88,11 @@ public Result hasEnded(@Nullable String simulateRoleRemoval) {
simulateRoleRemoval = null;
continue;
}
+ // Skip dead roles
+ if (player.getDeadRoles() != null && player.getDeadRoles().contains(role)) {
+ continue;
+ }
+
if (Player.isWolf(role)) {
wolves++;
if (player.isPolice())
@@ -82,14 +116,15 @@ public Result hasEnded(@Nullable String simulateRoleRemoval) {
if (jinBaoBao == 0)
return Result.JIN_BAO_BAO_DIED;
} else {
- if (villagers == 0 && roles.contains("平民")) //support for an all gods game
+ if (villagers == 0 && roles.contains("平民")) // support for an all gods game
return Result.VILLAGERS_DIED;
}
if (policeOnGood)
- villagers += 0.5;
+ villagers += 0.5f;
if (policeOnWolf)
- wolves += 0.5;
- if ((wolves >= gods + villagers) && !doubleIdentities) // we don't do equal players ending in double identities, too annoying
+ wolves += 0.5f;
+ if ((wolves >= gods + villagers) && !doubleIdentities) // we don't do equal players ending in double identities,
+ // too annoying
return Result.EQUAL_PLAYERS;
return Result.NOT_ENDED;
}
@@ -112,6 +147,7 @@ public enum Result {
@NoArgsConstructor
@AllArgsConstructor
public static class Player implements Comparable {
+ public static final NumberFormat ID_FORMAT = new DecimalFormat("00");
private int id;
private long roleId;
private long channelId;
@@ -130,6 +166,17 @@ public static class Player implements Comparable {
@Nullable
@Builder.Default
private List roles = new LinkedList<>(); // stuff like wolf, villager...etc
+ @Nullable
+ @Builder.Default
+ private List deadRoles = new LinkedList<>();
+
+ public boolean isAlive() {
+ if (roles == null || roles.isEmpty())
+ return false;
+ if (deadRoles == null)
+ return true;
+ return deadRoles.size() < roles.size();
+ }
private static boolean isGod(String role) {
return (!isWolf(role)) && (!isVillager(role));
@@ -143,9 +190,75 @@ private static boolean isVillager(String role) {
return role.equals("平民");
}
- @Override
public int compareTo(@NotNull Session.Player o) {
return Integer.compare(id, o.id);
}
+
+ public String getNickname() {
+ StringBuilder sb = new StringBuilder();
+
+ if (!isAlive()) {
+ sb.append("[死人] ");
+ }
+
+ sb.append("玩家").append(ID_FORMAT.format(id));
+
+ if (isPolice()) {
+ sb.append(" [警長]");
+ }
+
+ return sb.toString();
+ }
+
+ public void updateNickname(net.dv8tion.jda.api.entities.Member member) {
+ if (member == null)
+ return;
+
+ String newName = getNickname();
+ if (!member.getEffectiveName().equals(newName)) {
+ member.modifyNickname(newName).queue();
+ }
+ }
+ }
+
+ /**
+ * Audit log entry for tracking game events
+ */
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class LogEntry {
+ private String id;
+ private long timestamp;
+ private LogType type;
+ private String message;
+ private Map metadata;
+ }
+
+ /**
+ * Add a log entry to the session
+ */
+ public void addLog(LogType type, String message) {
+ addLog(type, message, null);
+ }
+
+ /**
+ * Add a log entry with metadata to the session
+ */
+ public void addLog(LogType type, String message, Map metadata) {
+ LogEntry entry = LogEntry.builder()
+ .id(UUID.randomUUID().toString())
+ .timestamp(System.currentTimeMillis())
+ .type(type)
+ .message(message)
+ .metadata(metadata)
+ .build();
+ logs.add(entry);
+
+ // Persist to database
+ fetchCollection().updateOne(
+ eq("guildId", guildId),
+ com.mongodb.client.model.Updates.push("logs", entry));
}
}
diff --git a/src/main/java/dev/robothanzo/werewolf/database/documents/UserRole.java b/src/main/java/dev/robothanzo/werewolf/database/documents/UserRole.java
new file mode 100644
index 0000000..01cc7cd
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/database/documents/UserRole.java
@@ -0,0 +1,31 @@
+package dev.robothanzo.werewolf.database.documents;
+
+import lombok.Getter;
+
+@Getter
+public enum UserRole {
+ JUDGE("法官"),
+ SPECTATOR("觀眾"),
+ PENDING("待定"),
+ BLOCKED("已封鎖");
+
+ private final String description;
+
+ UserRole(String description) {
+ this.description = description;
+ }
+
+ public boolean isPrivileged() {
+ return this == JUDGE || this == SPECTATOR;
+ }
+
+ public static UserRole fromString(String role) {
+ if (role == null)
+ return PENDING;
+ try {
+ return UserRole.valueOf(role.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return PENDING;
+ }
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/listeners/ButtonListener.java b/src/main/java/dev/robothanzo/werewolf/listeners/ButtonListener.java
index 553710f..23b9dfc 100644
--- a/src/main/java/dev/robothanzo/werewolf/listeners/ButtonListener.java
+++ b/src/main/java/dev/robothanzo/werewolf/listeners/ButtonListener.java
@@ -1,12 +1,14 @@
package dev.robothanzo.werewolf.listeners;
+import dev.robothanzo.werewolf.WerewolfApplication;
+import dev.robothanzo.werewolf.commands.Player;
import dev.robothanzo.werewolf.commands.Poll;
import dev.robothanzo.werewolf.database.documents.Session;
+import dev.robothanzo.werewolf.model.Candidate;
+import dev.robothanzo.werewolf.model.PoliceSession;
import dev.robothanzo.werewolf.utils.CmdUtils;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.EntitySelectInteractionEvent;
-import dev.robothanzo.werewolf.commands.Player;
-import dev.robothanzo.werewolf.commands.Speech;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;
@@ -18,8 +20,9 @@ public class ButtonListener extends ListenerAdapter {
@Override
public void onButtonInteraction(@NotNull ButtonInteractionEvent event) {
String customId = event.getButton().getCustomId();
- if (customId == null) return;
-
+ if (customId == null)
+ return;
+
String[] id = customId.split(":");
switch (id[0]) {
@@ -31,25 +34,33 @@ public void onButtonInteraction(@NotNull ButtonInteractionEvent event) {
Player.destroyPolice(event);
return;
}
- case "rolesList" -> {
- Poll.sendRolesList(event);
- return;
- }
case "terminateTimer" -> {
- Speech.terminateTimer(event);
+ event.deferReply(true).queue();
+ if (CmdUtils.isAdmin(event)) {
+ try {
+ WerewolfApplication.speechService.stopTimer(event.getChannel().getIdLong());
+ event.getHook().editOriginal(":white_check_mark:").queue();
+ } catch (Exception e) {
+ event.getHook().editOriginal(":x:").queue();
+ }
+ } else {
+ event.getHook().editOriginal(":x:").queue();
+ }
return;
}
}
- if (!customId.startsWith("vote")) return;
+ if (!customId.startsWith("vote"))
+ return;
event.deferReply(true).queue();
Session session = CmdUtils.getSession(event);
- if (session == null) return;
+ if (session == null)
+ return;
Session.Player player = null;
boolean check = false;
- for (Session.Player p : session.getPlayers().values()) {
+ for (Session.Player p : session.fetchAlivePlayers().values()) {
if (p.getUserId() != null && p.getUserId() == event.getUser().getIdLong()) {
check = true;
player = p;
@@ -65,28 +76,43 @@ public void onButtonInteraction(@NotNull ButtonInteractionEvent event) {
return;
}
if (customId.startsWith("votePolice")) {
- if (Poll.Police.candidates.containsKey(Objects.requireNonNull(event.getGuild()).getIdLong())) {
- Map candidates = Poll.Police.candidates.get(Objects.requireNonNull(event.getGuild()).getIdLong());
+ long guildId = Objects.requireNonNull(event.getGuild()).getIdLong();
+ if (WerewolfApplication.policeService.getSessions().containsKey(guildId)) {
+ PoliceSession policeSession = WerewolfApplication.policeService.getSessions().get(guildId);
+ Map candidates = policeSession.getCandidates();
+
if (candidates.containsKey(player.getId())) {
event.getHook().editOriginal(":x: 你曾經參選過或正在參選,不得投票").queue();
return;
}
- Poll.Candidate electedCandidate = candidates.get(Integer.parseInt(customId.replaceAll("votePolice", "")));
- handleVote(event, candidates, electedCandidate);
+ Candidate electedCandidate = candidates
+ .get(Integer.parseInt(customId.replaceAll("votePolice", "")));
+ if (electedCandidate != null) {
+ handleVote(event, candidates, electedCandidate);
+ // Broadcast update immediately
+ WerewolfApplication.gameSessionService.broadcastSessionUpdate(session);
+ } else {
+ event.getHook().editOriginal(":x: 找不到候選人").queue();
+ }
} else {
event.getHook().editOriginal(":x: 投票已過期").queue();
}
}
if (customId.startsWith("voteExpel")) {
if (Poll.expelCandidates.containsKey(Objects.requireNonNull(event.getGuild()).getIdLong())) {
- Poll.Candidate votingCandidate = Poll.expelCandidates.get(Objects.requireNonNull(event.getGuild()).getIdLong()).get(player.getId());
+ Candidate votingCandidate = Poll.expelCandidates
+ .get(Objects.requireNonNull(event.getGuild()).getIdLong()).get(player.getId());
if (votingCandidate != null && votingCandidate.isExpelPK()) {
event.getHook().editOriginal(":x: 你正在和別人進行放逐辯論,不得投票").queue();
return;
}
- Map candidates = Poll.expelCandidates.get(Objects.requireNonNull(event.getGuild()).getIdLong());
- Poll.Candidate electedCandidate = candidates.get(Integer.parseInt(customId.replaceAll("voteExpel", "")));
+ Map candidates = Poll.expelCandidates
+ .get(Objects.requireNonNull(event.getGuild()).getIdLong());
+ Candidate electedCandidate = candidates
+ .get(Integer.parseInt(customId.replaceAll("voteExpel", "")));
handleVote(event, candidates, electedCandidate);
+ // Broadcast update immediately for expel (user requested realtime voting)
+ WerewolfApplication.gameSessionService.broadcastSessionUpdate(session);
} else {
event.getHook().editOriginal(":x: 投票已過期").queue();
}
@@ -100,9 +126,10 @@ public void onEntitySelectInteraction(@NotNull EntitySelectInteractionEvent even
}
}
- public void handleVote(@NotNull ButtonInteractionEvent event, Map candidates, Poll.Candidate electedCandidate) {
+ public void handleVote(@NotNull ButtonInteractionEvent event, Map candidates,
+ Candidate electedCandidate) {
boolean handled = false;
- for (Poll.Candidate candidate : new LinkedList<>(candidates.values())) {
+ for (Candidate candidate : new LinkedList<>(candidates.values())) {
if (candidate.getElectors().contains(event.getUser().getIdLong())) {
if (Objects.equals(candidate.getPlayer().getUserId(), electedCandidate.getPlayer().getUserId())) {
electedCandidate.getElectors().remove(event.getUser().getIdLong());
diff --git a/src/main/java/dev/robothanzo/werewolf/listeners/GuildJoinListener.java b/src/main/java/dev/robothanzo/werewolf/listeners/GuildJoinListener.java
index ec2d0d9..6dcb192 100644
--- a/src/main/java/dev/robothanzo/werewolf/listeners/GuildJoinListener.java
+++ b/src/main/java/dev/robothanzo/werewolf/listeners/GuildJoinListener.java
@@ -1,22 +1,21 @@
package dev.robothanzo.werewolf.listeners;
+import dev.robothanzo.werewolf.WerewolfApplication;
import dev.robothanzo.werewolf.commands.Server;
+import dev.robothanzo.werewolf.database.documents.Session;
import dev.robothanzo.werewolf.utils.SetupHelper;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.events.guild.GuildJoinEvent;
import net.dv8tion.jda.api.events.guild.GuildLeaveEvent;
import net.dv8tion.jda.api.events.guild.member.GuildMemberRoleAddEvent;
-import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
-import dev.robothanzo.werewolf.commands.Speech;
-
-import static com.mongodb.client.model.Filters.eq;
-
-import dev.robothanzo.werewolf.database.documents.Session;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;
+import static com.mongodb.client.model.Filters.eq;
+
@Slf4j
public class GuildJoinListener extends ListenerAdapter {
@Override
@@ -35,13 +34,13 @@ public void onGuildMemberRoleAdd(@NotNull GuildMemberRoleAddEvent event) {
public void onGuildLeave(@NotNull GuildLeaveEvent event) {
log.info("Bot left guild: {}", event.getGuild().getId());
Session.fetchCollection().deleteOne(eq("guildId", event.getGuild().getIdLong()));
- Speech.speechSessions.remove(event.getGuild().getIdLong());
+ WerewolfApplication.speechService.interruptSession(event.getGuild().getIdLong());
}
private void checkAndSetup(Guild guild) {
long ownerId = guild.getOwnerIdLong();
-
- // Remove from pending only if setup starts successfully or we handle it?
+
+ // Remove from pending only if setup starts successfully or we handle it?
// Better to check existence first.
if (Server.pendingSetups.containsKey(ownerId)) {
if (guild.getSelfMember().hasPermission(Permission.ADMINISTRATOR)) {
@@ -50,10 +49,10 @@ private void checkAndSetup(Guild guild) {
SetupHelper.setup(guild, setupConfig);
} else {
// Try to warn in default channel
- if (guild.getDefaultChannel() != null && guild.getDefaultChannel() instanceof TextChannel && ((TextChannel) guild.getDefaultChannel()).canTalk()) {
+ if (guild.getDefaultChannel() != null && guild.getDefaultChannel() instanceof TextChannel
+ && ((TextChannel) guild.getDefaultChannel()).canTalk()) {
((TextChannel) guild.getDefaultChannel()).sendMessage(
- ":warning: 機器人需要 **管理員 (Administrator)** 權限才能設定伺服器。請授予權限後,設定將會自動開始。"
- ).queue();
+ ":warning: 機器人需要 **管理員 (Administrator)** 權限才能設定伺服器。請授予權限後,設定將會自動開始。").queue();
}
}
}
diff --git a/src/main/java/dev/robothanzo/werewolf/listeners/MemberJoinListener.java b/src/main/java/dev/robothanzo/werewolf/listeners/MemberJoinListener.java
index f16e798..686bdbe 100644
--- a/src/main/java/dev/robothanzo/werewolf/listeners/MemberJoinListener.java
+++ b/src/main/java/dev/robothanzo/werewolf/listeners/MemberJoinListener.java
@@ -1,6 +1,6 @@
package dev.robothanzo.werewolf.listeners;
-import dev.robothanzo.werewolf.WerewolfHelper;
+import dev.robothanzo.werewolf.WerewolfApplication;
import dev.robothanzo.werewolf.database.documents.Session;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent;
@@ -16,7 +16,7 @@ public class MemberJoinListener extends ListenerAdapter {
@Override
public void onGuildMemberJoin(@NotNull GuildMemberJoinEvent event) {
Session session = Session.fetchCollection().find(eq("guildId", event.getGuild().getIdLong())).first();
- if (WerewolfHelper.SERVER_CREATORS.contains(event.getMember().getIdLong())) {
+ if (WerewolfApplication.SERVER_CREATORS.contains(event.getMember().getIdLong())) {
if (session != null && session.getOwner() == event.getUser().getIdLong()) {
event.getGuild().addRoleToMember(event.getMember(),
Objects.requireNonNull(event.getGuild().getRoleById(session.getJudgeRoleId()))).queue();
diff --git a/src/main/java/dev/robothanzo/werewolf/listeners/MessageListener.java b/src/main/java/dev/robothanzo/werewolf/listeners/MessageListener.java
index aa5db87..27caf99 100644
--- a/src/main/java/dev/robothanzo/werewolf/listeners/MessageListener.java
+++ b/src/main/java/dev/robothanzo/werewolf/listeners/MessageListener.java
@@ -36,9 +36,12 @@ public static WebhookClient getWebhookClientOrCreate(TextChannel channel) {
}
private boolean isCharacterAlive(Session session, String character) {
- for (Session.Player player : session.getPlayers().values()) {
+ for (Session.Player player : session.fetchAlivePlayers().values()) {
if (player.getRoles() != null && player.getRoles().contains(character)) {
- return true;
+ // Check if this specific role is NOT dead
+ if (player.getDeadRoles() == null || !player.getDeadRoles().contains(character)) {
+ return true;
+ }
}
}
return false;
@@ -46,6 +49,17 @@ private boolean isCharacterAlive(Session session, String character) {
private boolean shouldSend(Session.Player player, Session session) {
assert player.getRoles() != null;
+ // Check first role (primary role for speech?) or any active role?
+ // Logic seems to assume specific roles.
+ // Assuming primary role is relevant, but with soft kill, roles order matters.
+ // We should probably check if the role enabling speech is ALIVE.
+ // But original code uses getRoles().getFirst().
+ // If first role is dead in soft kill (but player alive), should he speak?
+ // Probably not as that role.
+ // But let's keep getFirst() for now, or check deadRoles.
+ // Ideally we check if ANY of the enabling roles are alive.
+ // But complicating this might break assumption that first role = current identity.
+ // For now, let's just stick to replacement of getPlayers -> fetchAlivePlayers.
return player.getRoles().getFirst().contains("狼人") ||
player.getRoles().contains("狼兄") ||
player.getRoles().getFirst().contains("狼王") ||
@@ -61,17 +75,17 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) {
if (event.getAuthor().isBot()) return;
Session session = CmdUtils.getSession(event.getGuild());
if (session == null) return;
- for (Session.Player player : session.getPlayers().values()) {
+ for (Session.Player player : session.fetchAlivePlayers().values()) {
if (player.getRoles() != null && !player.getRoles().isEmpty()) {
if (shouldSend(player, session) && player.getChannelId() == event.getChannel().getIdLong()
|| event.getChannel().getIdLong() == session.getJudgeTextChannelId()) {
WebhookMessage message = new WebhookMessageBuilder()
.setContent(event.getMessage().getContentRaw())
- .setUsername((((event.getChannel().getIdLong() == session.getJudgeTextChannelId()) ? "法官頻道" : "玩家" + player.getId())) +
+ .setUsername((((event.getChannel().getIdLong() == session.getJudgeTextChannelId()) ? "法官頻道" : player.getNickname())) +
" (" + event.getAuthor().getName() + ")")
.setAvatarUrl(event.getAuthor().getAvatarUrl())
.build();
- for (Session.Player p : session.getPlayers().values()) {
+ for (Session.Player p : session.fetchAlivePlayers().values()) {
if (shouldSend(p, session) && event.getChannel().getIdLong() != p.getChannelId()) {
getWebhookClientOrCreate(Objects.requireNonNull(event.getGuild().getTextChannelById(p.getChannelId()))).send(message);
}
@@ -84,10 +98,10 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) {
&& player.getChannelId() == event.getChannel().getIdLong())) {
WebhookMessage message = new WebhookMessageBuilder()
.setContent(event.getMessage().getContentRaw())
- .setUsername("玩家" + player.getId() + " (" + event.getAuthor().getName() + ")")
+ .setUsername(player.getNickname() + " (" + event.getAuthor().getName() + ")")
.setAvatarUrl(event.getAuthor().getAvatarUrl())
.build();
- for (Session.Player p : session.getPlayers().values()) {
+ for (Session.Player p : session.fetchAlivePlayers().values()) {
if (p.isJinBaoBao() && event.getChannel().getIdLong() != p.getChannelId()) {
getWebhookClientOrCreate(Objects.requireNonNull(event.getGuild().getTextChannelById(p.getChannelId()))).send(message);
}
diff --git a/src/main/java/dev/robothanzo/werewolf/model/Candidate.java b/src/main/java/dev/robothanzo/werewolf/model/Candidate.java
new file mode 100644
index 0000000..7b000f9
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/model/Candidate.java
@@ -0,0 +1,64 @@
+package dev.robothanzo.werewolf.model;
+
+import dev.robothanzo.werewolf.database.documents.Session;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.LinkedList;
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class Candidate {
+ private Session.Player player;
+ @Builder.Default
+ private boolean expelPK = false;
+ @Builder.Default
+ private List electors = new LinkedList<>();
+ @Builder.Default
+ private boolean quit = false;
+
+ public static Comparator getComparator() {
+ return Comparator.comparingInt(o -> o.getPlayer().getId());
+ }
+
+ public static List getWinner(Collection candidates, @Nullable Session.Player police) {
+ List winners = new LinkedList<>();
+ float winningVotes = 0;
+ for (Candidate candidate : candidates) {
+ float votes = candidate.getVotes(police);
+ if (votes <= 0)
+ continue;
+ if (votes > winningVotes) {
+ winningVotes = votes;
+ winners.clear();
+ winners.add(candidate);
+ } else if (votes == winningVotes) {
+ winners.add(candidate);
+ }
+ }
+ return winners;
+ }
+
+ public List getElectorsAsMention() {
+ List result = new LinkedList<>();
+ for (Long elector : electors) {
+ result.add("<@!" + elector + ">");
+ }
+ return result;
+ }
+
+ public float getVotes(@Nullable Session.Player police) {
+ boolean hasPolice = police != null;
+ if (hasPolice)
+ hasPolice = electors.contains(police.getUserId());
+ return (float) ((electors.size() + (hasPolice ? 0.5 : 0)) * (quit ? 0 : 1));
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/model/PoliceSession.java b/src/main/java/dev/robothanzo/werewolf/model/PoliceSession.java
new file mode 100644
index 0000000..746d5f3
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/model/PoliceSession.java
@@ -0,0 +1,41 @@
+package dev.robothanzo.werewolf.model;
+
+import dev.robothanzo.werewolf.database.documents.Session;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import net.dv8tion.jda.api.entities.Message;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class PoliceSession {
+ private long guildId;
+ private long channelId;
+ private Session session;
+ @Builder.Default
+ private State state = State.NONE;
+ @Builder.Default
+ private long stageEndTime = 0;
+ @Builder.Default
+ private Map candidates = new ConcurrentHashMap<>();
+ @Builder.Default
+ private Message message = null;
+
+ public enum State {
+ NONE, ENROLLMENT, SPEECH, UNENROLLMENT, VOTING, FINISHED;
+
+ public boolean canEnroll() {
+ return this == ENROLLMENT;
+ }
+
+ public boolean canQuit() {
+ return this == ENROLLMENT || this == UNENROLLMENT;
+ }
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/model/SpeechOrder.java b/src/main/java/dev/robothanzo/werewolf/model/SpeechOrder.java
new file mode 100644
index 0000000..22c22a4
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/model/SpeechOrder.java
@@ -0,0 +1,26 @@
+package dev.robothanzo.werewolf.model;
+
+import net.dv8tion.jda.api.entities.emoji.Emoji;
+
+import java.util.Locale;
+
+public enum SpeechOrder {
+ UP, DOWN;
+
+ public static SpeechOrder getRandomOrder() {
+ return values()[(int) (Math.random() * values().length)];
+ }
+
+ public static SpeechOrder fromString(String s) {
+ return valueOf(s.toUpperCase(Locale.ROOT));
+ }
+
+ @Override
+ public String toString() {
+ return this == UP ? "往上" : "往下";
+ }
+
+ public Emoji toEmoji() {
+ return this == UP ? Emoji.fromUnicode("U+2b06") : Emoji.fromUnicode("U+2b07");
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/model/SpeechSession.java b/src/main/java/dev/robothanzo/werewolf/model/SpeechSession.java
new file mode 100644
index 0000000..91361eb
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/model/SpeechSession.java
@@ -0,0 +1,31 @@
+package dev.robothanzo.werewolf.model;
+
+import dev.robothanzo.werewolf.database.documents.Session;
+import lombok.Builder;
+import lombok.Data;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.LinkedList;
+import java.util.List;
+
+@Data
+@Builder
+public class SpeechSession {
+ private long guildId;
+ private long channelId;
+ private Session session;
+ @Builder.Default
+ private List interruptVotes = new LinkedList<>();
+ @Builder.Default
+ private List order = new LinkedList<>();
+ @Nullable
+ private Thread speakingThread;
+ @Nullable
+ private Long lastSpeaker;
+ @Nullable
+ private Runnable finishedCallback;
+ @Builder.Default
+ private long currentSpeechEndTime = 0;
+ @Builder.Default
+ private int totalSpeechTime = 0;
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/security/GlobalWebSocketHandler.java b/src/main/java/dev/robothanzo/werewolf/security/GlobalWebSocketHandler.java
new file mode 100644
index 0000000..35ca6f6
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/security/GlobalWebSocketHandler.java
@@ -0,0 +1,83 @@
+package dev.robothanzo.werewolf.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.socket.CloseStatus;
+import org.springframework.web.socket.TextMessage;
+import org.springframework.web.socket.WebSocketSession;
+import org.springframework.web.socket.handler.TextWebSocketHandler;
+
+import java.io.IOException;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+@Component
+public class GlobalWebSocketHandler extends TextWebSocketHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(GlobalWebSocketHandler.class);
+
+ // Maintain a list of sessions.
+ // In a real app with auth, you might map userId -> Session or guildId ->
+ // List
+ private final CopyOnWriteArrayList sessions = new CopyOnWriteArrayList<>();
+
+ @Override
+ public void afterConnectionEstablished(WebSocketSession session) throws Exception {
+ Object userObj = session.getAttributes().get("user");
+ if (!(userObj instanceof dev.robothanzo.werewolf.database.documents.AuthSession user)) {
+ log.warn("Rejected WS connection: No user in session");
+ session.close(CloseStatus.POLICY_VIOLATION);
+ return;
+ }
+
+ // Only allow connection if the user is a spectator or judge
+ if (!user.isPrivileged()) {
+ log.warn("Rejected WS connection: User {} has unauthorized role {}", user.getUserId(), user.getRole());
+ session.close(CloseStatus.POLICY_VIOLATION);
+ return;
+ }
+
+ // Check if listening to selected server
+ java.net.URI uri = session.getUri();
+ String query = uri != null ? uri.getQuery() : null;
+ String requestedGuildId = null;
+ if (query != null) {
+ requestedGuildId = java.util.stream.Stream.of(query.split("&"))
+ .filter(s -> s.startsWith("guildId="))
+ .map(s -> s.substring(8))
+ .findFirst()
+ .orElse(null);
+ }
+
+ if (requestedGuildId == null || !requestedGuildId.equals(user.getGuildId())) {
+ log.warn("Rejected WS connection: Guild mismatch. Requested: {}, Authorized: {}", requestedGuildId,
+ user.getGuildId());
+ session.close(CloseStatus.POLICY_VIOLATION);
+ return;
+ }
+
+ sessions.add(session);
+ log.info("WebSocket connection established for user {} in guild {}: {}", user.getUserId(), user.getGuildId(),
+ session.getId());
+ }
+
+ @Override
+ public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
+ sessions.remove(session);
+ log.info("WebSocket connection closed: " + session.getId());
+ }
+
+ public void broadcast(String message) {
+ for (WebSocketSession session : sessions) {
+ if (session.isOpen()) {
+ synchronized (session) {
+ try {
+ session.sendMessage(new TextMessage(message));
+ } catch (IOException e) {
+ log.error("Failed to send message to session " + session.getId(), e);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/security/SessionRepository.java b/src/main/java/dev/robothanzo/werewolf/security/SessionRepository.java
new file mode 100644
index 0000000..baf46e8
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/security/SessionRepository.java
@@ -0,0 +1,14 @@
+package dev.robothanzo.werewolf.security;
+
+import dev.robothanzo.werewolf.database.documents.Session;
+import org.springframework.data.mongodb.repository.MongoRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface SessionRepository extends MongoRepository {
+ Optional findByGuildId(long guildId);
+
+ void deleteByGuildId(long guildId);
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/security/annotations/CanManageGuild.java b/src/main/java/dev/robothanzo/werewolf/security/annotations/CanManageGuild.java
new file mode 100644
index 0000000..6349545
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/security/annotations/CanManageGuild.java
@@ -0,0 +1,14 @@
+package dev.robothanzo.werewolf.security.annotations;
+
+import org.springframework.security.access.prepost.PreAuthorize;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@PreAuthorize("@identityUtils.canManage(#guildId)")
+public @interface CanManageGuild {
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/security/annotations/CanViewGuild.java b/src/main/java/dev/robothanzo/werewolf/security/annotations/CanViewGuild.java
new file mode 100644
index 0000000..d3cd10b
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/security/annotations/CanViewGuild.java
@@ -0,0 +1,14 @@
+package dev.robothanzo.werewolf.security.annotations;
+
+import org.springframework.security.access.prepost.PreAuthorize;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@PreAuthorize("@identityUtils.canView(#guildId)")
+public @interface CanViewGuild {
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/DiscordService.java b/src/main/java/dev/robothanzo/werewolf/service/DiscordService.java
new file mode 100644
index 0000000..ce162fa
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/DiscordService.java
@@ -0,0 +1,55 @@
+package dev.robothanzo.werewolf.service;
+
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
+
+/**
+ * Service for interacting with the Discord JDA API.
+ * Provides helper methods to retrieve various Discord entities.
+ */
+public interface DiscordService {
+ /**
+ * Gets the JDA instance used by the application.
+ *
+ * @return the JDA instance
+ */
+ JDA getJDA();
+
+ /**
+ * Retrieves a Discord guild by its ID.
+ *
+ * @param guildId the ID of the guild
+ * @return the guild, or null if not found
+ */
+ Guild getGuild(long guildId);
+
+ /**
+ * Retrieves a member of a guild by their user ID.
+ *
+ * @param guildId the ID of the guild
+ * @param userId the ID of the user
+ * @return the member, or null if not found
+ */
+ Member getMember(long guildId, String userId);
+
+ /**
+ * Retrieves a text channel by its guild and channel ID.
+ *
+ * @param guildId the ID of the guild
+ * @param channelId the ID of the channel
+ * @return the text channel, or null if not found
+ */
+ TextChannel getTextChannel(long guildId, long channelId);
+
+ /**
+ * Retrieves a voice channel by its guild and channel ID.
+ *
+ * @param guildId the ID of the guild
+ * @param channelId the ID of the channel
+ * @return the voice channel, or null if not found
+ */
+ VoiceChannel getVoiceChannel(long guildId, long channelId);
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/GameActionService.java b/src/main/java/dev/robothanzo/werewolf/service/GameActionService.java
new file mode 100644
index 0000000..0a9df00
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/GameActionService.java
@@ -0,0 +1,62 @@
+package dev.robothanzo.werewolf.service;
+
+import java.util.function.Consumer;
+
+/**
+ * Service for performing game-related actions such as resetting the game,
+ * marking players as dead, reviving players, and updating game state.
+ */
+public interface GameActionService {
+ /**
+ * Resets the game session for a specific guild.
+ *
+ * @param guildId the ID of the guild
+ * @param statusCallback callback for status messages
+ * @param progressCallback callback for progress updates (0-100)
+ * @throws Exception if an error occurs during the reset process
+ */
+ void resetGame(long guildId, Consumer statusCallback, Consumer progressCallback) throws Exception;
+
+ /**
+ * Marks a player as dead in the game session.
+ *
+ * @param guildId the ID of the guild
+ * @param userId the ID of the user to mark as dead
+ * @param allowLastWords whether the player is allowed to give last words
+ */
+ void markPlayerDead(long guildId, long userId, boolean allowLastWords);
+
+ /**
+ * Revives a player in the game session.
+ *
+ * @param guildId the ID of the guild
+ * @param userId the ID of the user to revive
+ */
+ void revivePlayer(long guildId, long userId);
+
+ /**
+ * Revives a player and assigns them a specific role.
+ *
+ * @param guildId the ID of the guild
+ * @param userId the ID of the user to revive
+ * @param role the role to assign to the player
+ */
+ void reviveRole(long guildId, long userId, String role);
+
+ /**
+ * Assigns the police (sheriff) status to a player.
+ *
+ * @param guildId the ID of the guild
+ * @param userId the ID of the user to make police
+ */
+ void setPolice(long guildId, long userId);
+
+ /**
+ * Broadcasts a progress update for a long-running action.
+ *
+ * @param guildId the ID of the guild
+ * @param message the progress message
+ * @param percent the progress percentage (0-100)
+ */
+ void broadcastProgress(long guildId, String message, Integer percent);
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/GameSessionService.java b/src/main/java/dev/robothanzo/werewolf/service/GameSessionService.java
new file mode 100644
index 0000000..7cf4ebc
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/GameSessionService.java
@@ -0,0 +1,126 @@
+package dev.robothanzo.werewolf.service;
+
+import dev.robothanzo.werewolf.database.documents.Session;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Service for managing game sessions, including creation, retrieval,
+ * persistence, and broadcasting updates to clients.
+ */
+public interface GameSessionService {
+ /**
+ * Retrieves all active game sessions.
+ *
+ * @return a list of all sessions
+ */
+ List getAllSessions();
+
+ /**
+ * Retrieves a game session for a specific guild.
+ *
+ * @param guildId the ID of the guild
+ * @return an Optional containing the session if found, or empty otherwise
+ */
+ Optional getSession(long guildId);
+
+ /**
+ * Creates a new game session for a guild.
+ *
+ * @param guildId the ID of the guild
+ * @return the newly created session
+ */
+ Session createSession(long guildId);
+
+ /**
+ * Saves or updates a game session in persistence.
+ *
+ * @param session the session to save
+ * @return the saved session
+ */
+ Session saveSession(Session session);
+
+ /**
+ * Deletes a game session for a guild.
+ *
+ * @param guildId the ID of the guild
+ */
+ void deleteSession(long guildId);
+
+ /**
+ * Serializes a game session to a JSON-compatible Map.
+ *
+ * @param session the session to serialize
+ * @return the serialized session map
+ */
+ Map sessionToJSON(Session session);
+
+ /**
+ * Serializes a game session to a summary JSON-compatible Map.
+ *
+ * @param session the session to serialize
+ * @return the serialized session summary map
+ */
+ Map sessionToSummaryJSON(Session session);
+
+ /**
+ * Serializes the players of a session to a JSON-compatible List of Maps.
+ *
+ * @param session the session containing the players
+ * @return a list of serialized player maps
+ */
+ List> playersToJSON(Session session);
+
+ /**
+ * Retrieves all members of a guild for management purposes.
+ *
+ * @param guildId the ID of the guild
+ * @return a list of member maps containing user details and roles
+ * @throws Exception if an error occurs during retrieval
+ */
+ List> getGuildMembers(long guildId) throws Exception;
+
+ /**
+ * Updates the custom role for a user within a guild.
+ *
+ * @param guildId the ID of the guild
+ * @param userId the ID of the user
+ * @param role the new role to assign
+ * @throws Exception if an error occurs during the update
+ */
+ void updateUserRole(long guildId, long userId, dev.robothanzo.werewolf.database.documents.UserRole role)
+ throws Exception;
+
+ /**
+ * Updates the settings for a game session.
+ *
+ * @param guildId the ID of the guild
+ * @param settings a map of setting keys and values to update
+ * @throws Exception if an error occurs during the update
+ */
+ void updateSettings(long guildId, Map settings) throws Exception;
+
+ /**
+ * Broadcasts a session update to all connected WebSocket clients for a guild.
+ *
+ * @param guildId the ID of the guild
+ */
+ void broadcastUpdate(long guildId);
+
+ /**
+ * Broadcasts a specific session update to all connected WebSocket clients.
+ *
+ * @param session the session that was updated
+ */
+ void broadcastSessionUpdate(Session session);
+
+ /**
+ * Broadcasts a general event to all connected WebSocket clients.
+ *
+ * @param type the type/name of the event
+ * @param data the data associated with the event
+ */
+ void broadcastEvent(String type, Map data);
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/PlayerService.java b/src/main/java/dev/robothanzo/werewolf/service/PlayerService.java
new file mode 100644
index 0000000..98889f3
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/PlayerService.java
@@ -0,0 +1,57 @@
+package dev.robothanzo.werewolf.service;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Service for managing individual players within a game session,
+ * including their roles, statuses, and ordering.
+ */
+public interface PlayerService {
+ /**
+ * Retrieves all players of a guild serialized to a JSON-compatible List of
+ * Maps.
+ *
+ * @param guildId the ID of the guild
+ * @return a list of serialized player maps
+ */
+ List> getPlayersJSON(long guildId);
+
+ /**
+ * Initializes the player count for a game session.
+ *
+ * @param guildId the ID of the guild
+ * @param count the number of players to create
+ * @param onProgress callback for progress messages
+ * @param onPercent callback for progress percentage (0-100)
+ * @throws Exception if an error occurs during initialization
+ */
+ void setPlayerCount(long guildId, int count, java.util.function.Consumer onProgress,
+ java.util.function.Consumer onPercent) throws Exception;
+
+ /**
+ * Updates the roles assigned to a specific player.
+ *
+ * @param guildId the ID of the guild
+ * @param playerId the ID of the player (e.g., "1", "2")
+ * @param roles the list of roles to assign
+ */
+ void updatePlayerRoles(long guildId, String playerId, List roles);
+
+ /**
+ * Toggles the order of roles for a player (between first and second role).
+ *
+ * @param guildId the ID of the guild
+ * @param playerId the ID of the player
+ */
+ void switchRoleOrder(long guildId, String playerId);
+
+ /**
+ * Locks or unlocks the role position for a specific player.
+ *
+ * @param guildId the ID of the guild
+ * @param playerId the ID of the player
+ * @param locked true to lock the roles, false to unlock
+ */
+ void setRolePositionLock(long guildId, String playerId, boolean locked);
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/PoliceService.java b/src/main/java/dev/robothanzo/werewolf/service/PoliceService.java
new file mode 100644
index 0000000..a2688f6
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/PoliceService.java
@@ -0,0 +1,60 @@
+package dev.robothanzo.werewolf.service;
+
+import dev.robothanzo.werewolf.database.documents.Session;
+import dev.robothanzo.werewolf.model.PoliceSession;
+import net.dv8tion.jda.api.entities.Message;
+import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
+import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
+
+import java.util.Map;
+
+/**
+ * Service for managing police (sheriff) election processes,
+ * including enrollment, speech management, and voting.
+ */
+public interface PoliceService {
+ /**
+ * Retrieves all active police election sessions.
+ *
+ * @return a map of guild IDs to their active police sessions
+ */
+ Map getSessions();
+
+ /**
+ * Starts the police enrollment phase for a game session.
+ *
+ * @param session the game session
+ * @param channel the Discord channel where the election is taking place
+ * @param message an optional message to reply to or edit
+ */
+ void startEnrollment(Session session, GuildMessageChannel channel, Message message);
+
+ /**
+ * Handles a user's interaction when they click the "Enroll in Police" button.
+ *
+ * @param event the button interaction event
+ */
+ void enrollPolice(ButtonInteractionEvent event);
+
+ /**
+ * Advances the police election process to the next stage (enrollment -> speech
+ * -> voting).
+ *
+ * @param guildId the ID of the guild
+ */
+ void next(long guildId);
+
+ /**
+ * Interrupts and ends the police election process for a guild.
+ *
+ * @param guildId the ID of the guild
+ */
+ void interrupt(long guildId);
+
+ /**
+ * Forcefully transitions the election stage to the voting phase.
+ *
+ * @param guildId the ID of the guild
+ */
+ void forceStartVoting(long guildId);
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/RoleService.java b/src/main/java/dev/robothanzo/werewolf/service/RoleService.java
new file mode 100644
index 0000000..c826120
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/RoleService.java
@@ -0,0 +1,47 @@
+package dev.robothanzo.werewolf.service;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Service for managing the roles available in a game session
+ * and handling the assignment of these roles to players.
+ */
+public interface RoleService {
+ /**
+ * Adds a specific amount of a role to the role pool for a guild.
+ *
+ * @param guildId the ID of the guild
+ * @param roleName the name of the role to add
+ * @param amount the amount of the role to add
+ */
+ void addRole(long guildId, String roleName, int amount);
+
+ /**
+ * Removes a specific amount of a role from the role pool for a guild.
+ *
+ * @param guildId the ID of the guild
+ * @param roleName the name of the role to remove
+ * @param amount the amount of the role to remove
+ */
+ void removeRole(long guildId, String roleName, int amount);
+
+ /**
+ * Retrieves the current role pool for a guild.
+ *
+ * @param guildId the ID of the guild
+ * @return a list of role names in the pool
+ */
+ List getRoles(long guildId);
+
+ /**
+ * Randomly assigns roles from the pool to the players in a game session.
+ *
+ * @param guildId the ID of the guild
+ * @param statusCallback callback for status messages during assignment
+ * @param progressCallback callback for progress percentage (0-100)
+ * @throws Exception if an error occurs during assignment
+ */
+ void assignRoles(long guildId, Consumer statusCallback, Consumer progressCallback)
+ throws Exception;
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/SpeechService.java b/src/main/java/dev/robothanzo/werewolf/service/SpeechService.java
new file mode 100644
index 0000000..8dd50cc
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/SpeechService.java
@@ -0,0 +1,91 @@
+package dev.robothanzo.werewolf.service;
+
+import dev.robothanzo.werewolf.database.documents.Session;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Message;
+import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
+import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
+
+import java.util.Collection;
+
+/**
+ * Service for managing speech sessions during the game,
+ * including auto-speech flow, last words, and timers.
+ */
+public interface SpeechService {
+ /**
+ * Starts a speech poll workflow.
+ */
+ void startSpeechPoll(Guild guild, Message enrollMessage, Collection players, Runnable callback);
+
+ /**
+ * Starts last words speech for a player.
+ */
+ void startLastWordsSpeech(Guild guild, long channelId, Session.Player player, Runnable callback);
+
+ /**
+ * Sets the speech order for a guild.
+ */
+ void setSpeechOrder(long guildId, dev.robothanzo.werewolf.model.SpeechOrder order);
+
+ /**
+ * Confirms the speech order and starts the speech flow for a guild.
+ */
+ void confirmSpeechOrder(long guildId);
+
+ /**
+ * Handles order selection from a dropdown.
+ */
+ void handleOrderSelection(StringSelectInteractionEvent event);
+
+ /**
+ * Confirms the selected order and starts the speech.
+ */
+ void confirmOrder(ButtonInteractionEvent event);
+
+ /**
+ * Skips the current speaker's turn.
+ */
+ void skipSpeech(ButtonInteractionEvent event);
+
+ /**
+ * Interrupts the current speaker's turn (e.g., via vote or admin).
+ */
+ void interruptSpeech(ButtonInteractionEvent event);
+
+ /**
+ * Starts the automatic speech flow for the daytime.
+ */
+ void startAutoSpeechFlow(long guildId, long channelId);
+
+ /**
+ * Starts a standalone timer.
+ */
+ void startTimer(long guildId, long channelId, long voiceChannelId, int seconds);
+
+ /**
+ * Stops a timer running in a specific channel.
+ */
+ void stopTimer(long channelId);
+
+ /**
+ * Gets the current speech session for a guild, if any.
+ * Use with caution, intended for read-only access.
+ */
+ dev.robothanzo.werewolf.model.SpeechSession getSpeechSession(long guildId);
+
+ /**
+ * Interrupts the entire speech session for a guild.
+ */
+ void interruptSession(long guildId);
+
+ /**
+ * Skips to the next speaker for a guild.
+ */
+ void skipToNext(long guildId);
+
+ /**
+ * Mutes or unmutes all non-admin members in a guild.
+ */
+ void setAllMute(long guildId, boolean mute);
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/impl/DiscordServiceImpl.java b/src/main/java/dev/robothanzo/werewolf/service/impl/DiscordServiceImpl.java
new file mode 100644
index 0000000..feea328
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/impl/DiscordServiceImpl.java
@@ -0,0 +1,116 @@
+package dev.robothanzo.werewolf.service.impl;
+
+import club.minnced.discord.jdave.interop.JDaveSessionFactory;
+import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers;
+import dev.robothanzo.jda.interactions.JDAInteractions;
+import dev.robothanzo.werewolf.database.Database;
+import dev.robothanzo.werewolf.listeners.ButtonListener;
+import dev.robothanzo.werewolf.listeners.GuildJoinListener;
+import dev.robothanzo.werewolf.listeners.MemberJoinListener;
+import dev.robothanzo.werewolf.listeners.MessageListener;
+import dev.robothanzo.werewolf.service.DiscordService;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.JDABuilder;
+import net.dv8tion.jda.api.audio.AudioModuleConfig;
+import net.dv8tion.jda.api.entities.Activity;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
+import net.dv8tion.jda.api.requests.GatewayIntent;
+import net.dv8tion.jda.api.utils.ChunkingFilter;
+import net.dv8tion.jda.api.utils.MemberCachePolicy;
+import net.dv8tion.jda.api.utils.cache.CacheFlag;
+import org.springframework.stereotype.Service;
+
+import java.util.EnumSet;
+
+import static dev.robothanzo.werewolf.WerewolfApplication.playerManager;
+
+@Slf4j
+@Service
+public class DiscordServiceImpl implements DiscordService {
+
+ private JDA jda;
+
+ @PostConstruct
+ public void init() {
+ log.info("Initializing Discord Service...");
+ try {
+ // Ensure Database is init (legacy)
+ // Database.initDatabase(); // Spring Boot likely handles DB via Spring Data,
+ // but if static utils use it...
+ // Ideally replace Database.initDatabase() with Spring Data usage.
+ // For now, let's keep it if legacy code depends on Database.database static
+ // field.
+ Database.initDatabase();
+
+ AudioSourceManagers.registerLocalSource(playerManager);
+
+ String token = System.getenv("TOKEN");
+ if (token == null || token.isEmpty()) {
+ log.error("TOKEN environment variable not set!");
+ return;
+ }
+
+ jda = JDABuilder.createDefault(token)
+ .enableIntents(GatewayIntent.GUILD_MEMBERS, GatewayIntent.MESSAGE_CONTENT)
+ .setChunkingFilter(ChunkingFilter.ALL)
+ .setMemberCachePolicy(MemberCachePolicy.ALL)
+ .enableCache(EnumSet.allOf(CacheFlag.class))
+ .disableCache(CacheFlag.ACTIVITY, CacheFlag.CLIENT_STATUS, CacheFlag.ONLINE_STATUS)
+ .addEventListeners(new GuildJoinListener(), new MemberJoinListener(), new MessageListener(),
+ new ButtonListener())
+ .setAudioModuleConfig(new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))
+ .build();
+
+ new JDAInteractions("dev.robothanzo.werewolf.commands").registerInteractions(jda).queue();
+ jda.awaitReady();
+ jda.getPresence().setActivity(Activity.competing("狼人殺 by Hanzo"));
+ log.info("JDA Initialized: {}", jda.getSelfUser().getAsTag());
+
+ // Set legacy static instance for backward compatibility
+ dev.robothanzo.werewolf.WerewolfApplication.jda = jda;
+
+ } catch (Exception e) {
+ log.error("Failed to initialize JDA", e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public JDA getJDA() {
+ return jda;
+ }
+
+ @Override
+ public Guild getGuild(long guildId) {
+ return jda.getGuildById(guildId);
+ }
+
+ @Override
+ public Member getMember(long guildId, String userId) {
+ Guild guild = getGuild(guildId);
+ if (guild == null)
+ return null;
+ return guild.getMemberById(userId);
+ }
+
+ @Override
+ public TextChannel getTextChannel(long guildId, long channelId) {
+ Guild guild = getGuild(guildId);
+ if (guild == null)
+ return null;
+ return guild.getTextChannelById(channelId);
+ }
+
+ @Override
+ public VoiceChannel getVoiceChannel(long guildId, long channelId) {
+ Guild guild = getGuild(guildId);
+ if (guild == null)
+ return null;
+ return guild.getVoiceChannelById(channelId);
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/impl/GameActionServiceImpl.java b/src/main/java/dev/robothanzo/werewolf/service/impl/GameActionServiceImpl.java
new file mode 100644
index 0000000..22f5065
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/impl/GameActionServiceImpl.java
@@ -0,0 +1,282 @@
+package dev.robothanzo.werewolf.service.impl;
+
+import dev.robothanzo.werewolf.security.SessionRepository;
+import dev.robothanzo.werewolf.service.DiscordService;
+import dev.robothanzo.werewolf.service.GameActionService;
+import dev.robothanzo.werewolf.service.GameSessionService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.function.Consumer;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class GameActionServiceImpl implements GameActionService {
+
+ private final GameSessionService gameSessionService;
+ private final DiscordService discordService;
+ private final SessionRepository sessionRepository;
+
+ @Override
+ public void resetGame(long guildId, Consumer statusCallback, Consumer progressCallback)
+ throws Exception {
+ var session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new RuntimeException("Session not found"));
+
+ if (progressCallback != null)
+ progressCallback.accept(0);
+ if (statusCallback != null)
+ statusCallback.accept("正在連線至 Discord...");
+
+ var jda = discordService.getJDA();
+ var guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ throw new Exception("Guild not found");
+ }
+
+ if (progressCallback != null)
+ progressCallback.accept(5);
+ if (statusCallback != null)
+ statusCallback.accept("正在掃描需要清理的身分組...");
+
+ java.util.List tasks = new java.util.ArrayList<>();
+ var spectatorRole = guild.getRoleById(session.getSpectatorRoleId());
+
+ for (var player : session.getPlayers().values()) {
+ Long currentUserId = player.getUserId();
+
+ player.setUserId(null);
+ player.setRoles(new java.util.ArrayList<>());
+ player.setDeadRoles(new java.util.ArrayList<>());
+ player.setPolice(false);
+ player.setIdiot(false);
+ player.setJinBaoBao(false);
+ player.setDuplicated(false);
+ player.setRolePositionLocked(false);
+
+ if (currentUserId != null) {
+ var member = guild.getMemberById(currentUserId);
+ if (member != null) {
+ if (spectatorRole != null) {
+ tasks.add(new dev.robothanzo.werewolf.utils.DiscordActionRunner.ActionTask(
+ guild.removeRoleFromMember(member, spectatorRole),
+ "已移除 " + member.getEffectiveName() + " 的旁觀者身分組"));
+ }
+
+ var playerRole = guild.getRoleById(player.getRoleId());
+ if (playerRole != null) {
+ tasks.add(new dev.robothanzo.werewolf.utils.DiscordActionRunner.ActionTask(
+ guild.removeRoleFromMember(member, playerRole),
+ "已移除玩家 " + player.getId() + " (" + member.getEffectiveName() + ") 的玩家身分組"));
+ }
+
+ // Nickname Reset
+ if (member.isOwner()) {
+ if (statusCallback != null)
+ statusCallback.accept(" - [資訊] 跳過群主 " + member.getEffectiveName() + " 的暱稱重置");
+ } else if (!guild.getSelfMember().canInteract(member)) {
+ if (statusCallback != null)
+ statusCallback.accept(" - [資訊] 無法重置 " + member.getEffectiveName() + " 的暱稱 (機器人權限不足)");
+ } else if (member.getNickname() != null) {
+ tasks.add(new dev.robothanzo.werewolf.utils.DiscordActionRunner.ActionTask(
+ member.modifyNickname(null),
+ "已重置 " + member.getEffectiveName() + " 的暱稱"));
+ }
+ }
+ }
+ }
+
+ if (!tasks.isEmpty()) {
+ if (statusCallback != null)
+ statusCallback.accept("正在執行 Discord 變更 (共 " + tasks.size() + " 項)...");
+ dev.robothanzo.werewolf.utils.DiscordActionRunner.runActions(tasks, statusCallback, progressCallback, 5, 90,
+ 30);
+ } else {
+ if (statusCallback != null)
+ statusCallback.accept("沒有偵測到需要清理的 Discord 變更。");
+ if (progressCallback != null)
+ progressCallback.accept(90);
+ }
+
+ if (statusCallback != null)
+ statusCallback.accept("正在更新資料庫並清理日誌...");
+
+ session.setLogs(new java.util.ArrayList<>());
+ session.setHasAssignedRoles(false);
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.GAME_RESET, "遊戲已重置", null);
+
+ sessionRepository.save(session);
+
+ if (progressCallback != null)
+ progressCallback.accept(100);
+ if (statusCallback != null)
+ statusCallback.accept("操作完成。");
+
+ gameSessionService.broadcastUpdate(guildId);
+ }
+
+ @Override
+ public void markPlayerDead(long guildId, long userId, boolean allowLastWords) {
+ try {
+ var session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new RuntimeException("Session not found"));
+
+ var jda = discordService.getJDA();
+ var guild = jda.getGuildById(guildId);
+ if (guild == null)
+ throw new Exception("Guild not found");
+
+ var member = guild.getMemberById(userId);
+ if (member == null) {
+ member = guild.retrieveMemberById(userId).complete();
+ }
+
+ boolean success = dev.robothanzo.werewolf.commands.Player.playerDied(session, member, allowLastWords,
+ false);
+ if (!success)
+ throw new Exception("Failed to mark player dead");
+
+ sessionRepository.save(session);
+ gameSessionService.broadcastUpdate(guildId);
+ } catch (Exception e) {
+ log.error("Failed to mark player dead: {}", e.getMessage(), e);
+ throw new RuntimeException("Failed to mark player dead", e);
+ }
+ }
+
+ @Override
+ public void revivePlayer(long guildId, long userId) {
+ try {
+ var session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new RuntimeException("Session not found"));
+
+ var jda = discordService.getJDA();
+ var guild = jda.getGuildById(guildId);
+ if (guild == null)
+ throw new Exception("Guild not found");
+
+ var member = guild.getMemberById(userId);
+ if (member == null) {
+ member = guild.retrieveMemberById(userId).complete();
+ }
+
+ dev.robothanzo.werewolf.database.documents.Session.Player targetPlayer = null;
+ for (var p : session.getPlayers().values()) {
+ if (p.getUserId() != null && p.getUserId() == userId) {
+ targetPlayer = p;
+ break;
+ }
+ }
+
+ if (targetPlayer == null || targetPlayer.getDeadRoles() == null || targetPlayer.getDeadRoles().isEmpty()) {
+ throw new Exception("Player has no dead roles to revive");
+ }
+
+ var rolesToRevive = new java.util.ArrayList<>(targetPlayer.getDeadRoles());
+ for (String role : rolesToRevive) {
+ dev.robothanzo.werewolf.commands.Player.playerRevived(session, member, role);
+ }
+
+ targetPlayer.updateNickname(member);
+ sessionRepository.save(session);
+ gameSessionService.broadcastUpdate(guildId);
+ } catch (Exception e) {
+ log.error("Failed to revive player: {}", e.getMessage(), e);
+ throw new RuntimeException("Failed to revive player", e);
+ }
+ }
+
+ @Override
+ public void reviveRole(long guildId, long userId, String role) {
+ try {
+ var session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new RuntimeException("Session not found"));
+
+ var jda = discordService.getJDA();
+ var guild = jda.getGuildById(guildId);
+ if (guild == null)
+ throw new Exception("Guild not found");
+
+ var member = guild.getMemberById(userId);
+ if (member == null) {
+ member = guild.retrieveMemberById(userId).complete();
+ }
+
+ boolean success = dev.robothanzo.werewolf.commands.Player.playerRevived(session, member, role);
+ if (!success)
+ throw new Exception("Failed to revive role");
+
+ sessionRepository.save(session);
+ gameSessionService.broadcastUpdate(guildId);
+ } catch (Exception e) {
+ log.error("Failed to revive player role: {}", e.getMessage(), e);
+ throw new RuntimeException("Failed to revive player role", e);
+ }
+ }
+
+ @Override
+ public void setPolice(long guildId, long userId) {
+ try {
+ var session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new RuntimeException("Session not found"));
+
+ var jda = discordService.getJDA();
+ var guild = jda.getGuildById(guildId);
+ if (guild == null)
+ throw new Exception("Guild not found");
+
+ for (var player : session.getPlayers().values()) {
+ if (player.isPolice()) {
+ player.setPolice(false);
+ if (player.getUserId() != null) {
+ var member = guild.getMemberById(player.getUserId());
+ if (member != null)
+ player.updateNickname(member);
+ }
+ }
+ }
+
+ dev.robothanzo.werewolf.database.documents.Session.Player targetPlayer = null;
+ for (var player : session.getPlayers().values()) {
+ if (player.getUserId() != null && player.getUserId() == userId) {
+ player.setPolice(true);
+ targetPlayer = player;
+ break;
+ }
+ }
+
+ if (targetPlayer == null)
+ throw new Exception("Player not found");
+
+ if (targetPlayer.getUserId() != null) {
+ var member = guild.getMemberById(targetPlayer.getUserId());
+ if (member != null) {
+ targetPlayer.updateNickname(member);
+ var courtChannel = guild.getTextChannelById(session.getCourtTextChannelId());
+ if (courtChannel != null) {
+ courtChannel.sendMessage(":white_check_mark: 警徽已移交給 " + member.getAsMention()).queue();
+ }
+ }
+ }
+
+ sessionRepository.save(session);
+ gameSessionService.broadcastUpdate(guildId);
+ } catch (Exception e) {
+ log.error("Failed to set police: {}", e.getMessage(), e);
+ throw new RuntimeException("Failed to set police", e);
+ }
+ }
+
+ @Override
+ public void broadcastProgress(long guildId, String message, Integer percent) {
+ java.util.Map data = new java.util.HashMap<>();
+ data.put("guildId", String.valueOf(guildId));
+ if (message != null)
+ data.put("message", message);
+ if (percent != null)
+ data.put("percent", percent);
+ gameSessionService.broadcastEvent("PROGRESS", data);
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/impl/GameSessionServiceImpl.java b/src/main/java/dev/robothanzo/werewolf/service/impl/GameSessionServiceImpl.java
new file mode 100644
index 0000000..d2c211f
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/impl/GameSessionServiceImpl.java
@@ -0,0 +1,433 @@
+package dev.robothanzo.werewolf.service.impl;
+
+import dev.robothanzo.werewolf.WerewolfApplication;
+import dev.robothanzo.werewolf.database.documents.Session;
+import dev.robothanzo.werewolf.database.documents.UserRole;
+import dev.robothanzo.werewolf.security.GlobalWebSocketHandler;
+import dev.robothanzo.werewolf.security.SessionRepository;
+import dev.robothanzo.werewolf.service.DiscordService;
+import dev.robothanzo.werewolf.service.GameSessionService;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Member;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class GameSessionServiceImpl implements GameSessionService {
+
+ private final SessionRepository sessionRepository;
+ private final DiscordService discordService;
+ private final GlobalWebSocketHandler webSocketHandler;
+ private final dev.robothanzo.werewolf.service.SpeechService speechService;
+
+ @PostConstruct
+ public void init() {
+ WerewolfApplication.gameSessionService = this;
+ }
+
+ @Override
+ public List getAllSessions() {
+ return sessionRepository.findAll();
+ }
+
+ @Override
+ public Optional getSession(long guildId) {
+ return sessionRepository.findByGuildId(guildId);
+ }
+
+ @Override
+ public Session createSession(long guildId) {
+ Session session = Session.builder()
+ .guildId(guildId)
+ .build();
+ return sessionRepository.save(session);
+ }
+
+ @Override
+ public Session saveSession(Session session) {
+ return sessionRepository.save(session);
+ }
+
+ @Override
+ public void deleteSession(long guildId) {
+ sessionRepository.deleteByGuildId(guildId);
+ }
+
+ @Override
+ public Map sessionToJSON(Session session) {
+ Map json = new java.util.LinkedHashMap<>();
+ var jda = discordService.getJDA();
+
+ json.put("guildId", String.valueOf(session.getGuildId()));
+ json.put("doubleIdentities", session.isDoubleIdentities());
+ json.put("muteAfterSpeech", session.isMuteAfterSpeech());
+ json.put("hasAssignedRoles", session.isHasAssignedRoles());
+ json.put("roles", session.getRoles());
+ json.put("players", playersToJSON(session));
+
+ // Add speech info if available
+ if (speechService.getSpeechSession(session.getGuildId()) != null) {
+ var speechSession = speechService.getSpeechSession(session.getGuildId());
+ Map speechJson = new java.util.LinkedHashMap<>();
+
+ List orderIds = new java.util.ArrayList<>();
+ for (Session.Player p : speechSession.getOrder()) {
+ orderIds.add(String.valueOf(p.getId()));
+ }
+ speechJson.put("order", orderIds);
+
+ if (speechSession.getLastSpeaker() != null) {
+ String speakerId = null;
+ for (Session.Player p : session.getPlayers().values()) {
+ if (p.getUserId() != null && p.getUserId().equals(speechSession.getLastSpeaker())) {
+ speakerId = String.valueOf(p.getId());
+ break;
+ }
+ }
+ speechJson.put("currentSpeakerId", speakerId);
+ }
+
+ speechJson.put("endTime", speechSession.getCurrentSpeechEndTime());
+ speechJson.put("totalTime", speechSession.getTotalSpeechTime());
+
+ List interruptVotes = new java.util.ArrayList<>();
+ for (Long uid : speechSession.getInterruptVotes()) {
+ interruptVotes.add(String.valueOf(uid));
+ }
+ speechJson.put("interruptVotes", interruptVotes);
+
+ json.put("speech", speechJson);
+ }
+
+ // Add Police/Poll info
+ Map policeJson = new java.util.LinkedHashMap<>();
+ long gid = session.getGuildId();
+
+ var policeSession = WerewolfApplication.policeService.getSessions().get(gid);
+ if (policeSession != null) {
+ policeJson.put("state", policeSession.getState().name());
+ policeJson.put("stageEndTime", policeSession.getStageEndTime());
+ policeJson.put("allowEnroll", policeSession.getState().canEnroll());
+ policeJson.put("allowUnEnroll", policeSession.getState().canQuit());
+
+ List> candidatesList = new java.util.ArrayList<>();
+ for (var c : policeSession.getCandidates().values()) {
+ Map candidateJson = new java.util.LinkedHashMap<>();
+ candidateJson.put("id", String.valueOf(c.getPlayer().getId()));
+ candidateJson.put("quit", c.isQuit());
+ List voters = new java.util.ArrayList<>();
+ for (Long voterId : c.getElectors()) {
+ voters.add(String.valueOf(voterId));
+ }
+ candidateJson.put("voters", voters);
+ candidatesList.add(candidateJson);
+ }
+ policeJson.put("candidates", candidatesList);
+ } else {
+ policeJson.put("state", "NONE");
+ policeJson.put("allowEnroll", false);
+ policeJson.put("allowUnEnroll", false);
+ policeJson.put("candidates", java.util.Collections.emptyList());
+ }
+ json.put("police", policeJson);
+
+ // Add Expel info
+ Map expelJson = new java.util.LinkedHashMap<>();
+ if (dev.robothanzo.werewolf.commands.Poll.expelCandidates.containsKey(gid)) {
+ List> expelCandidatesList = new java.util.ArrayList<>();
+ for (var c : dev.robothanzo.werewolf.commands.Poll.expelCandidates.get(gid).values()) {
+ Map candidateJson = new java.util.LinkedHashMap<>();
+ candidateJson.put("id", String.valueOf(c.getPlayer().getId()));
+ List voters = new java.util.ArrayList<>();
+ for (Long voterId : c.getElectors()) {
+ voters.add(String.valueOf(voterId));
+ }
+ candidateJson.put("voters", voters);
+ expelCandidatesList.add(candidateJson);
+ }
+ expelJson.put("candidates", expelCandidatesList);
+ expelJson.put("voting", true);
+ } else {
+ expelJson.put("candidates", java.util.Collections.emptyList());
+ expelJson.put("voting", false);
+ }
+ json.put("expel", expelJson);
+
+ if (jda != null) {
+ Guild guild = jda.getGuildById(session.getGuildId());
+ if (guild != null) {
+ json.put("guildName", guild.getName());
+ json.put("guildIcon", guild.getIconUrl());
+ }
+ }
+
+ List> logsJson = new java.util.ArrayList<>();
+ if (session.getLogs() != null) {
+ for (Session.LogEntry log : session.getLogs()) {
+ Map logJson = new java.util.LinkedHashMap<>();
+ logJson.put("id", log.getId());
+ logJson.put("timestamp", formatTimestamp(log.getTimestamp()));
+ logJson.put("type", log.getType().getSeverity());
+ logJson.put("message", log.getMessage());
+ if (log.getMetadata() != null && !log.getMetadata().isEmpty()) {
+ logJson.put("metadata", log.getMetadata());
+ }
+ logsJson.add(logJson);
+ }
+ }
+ json.put("logs", logsJson);
+
+ return json;
+ }
+
+ private String formatTimestamp(long epochMillis) {
+ java.time.Instant instant = java.time.Instant.ofEpochMilli(epochMillis);
+ java.time.ZoneId zoneId = java.time.ZoneId.systemDefault();
+ java.time.LocalDateTime dateTime = java.time.LocalDateTime.ofInstant(instant, zoneId);
+ java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss");
+ return dateTime.format(formatter);
+ }
+
+ @Override
+ public List> playersToJSON(Session session) {
+ List> players = new java.util.ArrayList<>();
+ var jda = discordService.getJDA();
+
+ for (Map.Entry entry : session.getPlayers().entrySet()) {
+ Session.Player player = entry.getValue();
+ Map playerJson = new java.util.LinkedHashMap<>();
+
+ playerJson.put("id", String.valueOf(player.getId()));
+ playerJson.put("roleId", String.valueOf(player.getRoleId()));
+ playerJson.put("channelId", String.valueOf(player.getChannelId()));
+ playerJson.put("userId", player.getUserId() != null ? String.valueOf(player.getUserId()) : null);
+ playerJson.put("roles", player.getRoles());
+ playerJson.put("deadRoles", player.getDeadRoles());
+ playerJson.put("isAlive", player.isAlive());
+ playerJson.put("jinBaoBao", player.isJinBaoBao());
+ playerJson.put("police", player.isPolice());
+ playerJson.put("idiot", player.isIdiot());
+ playerJson.put("duplicated", player.isDuplicated());
+ playerJson.put("rolePositionLocked", player.isRolePositionLocked());
+
+ boolean foundMember = false;
+ if (jda != null && player.getUserId() != null) {
+ Guild guild = jda.getGuildById(session.getGuildId());
+ if (guild != null) {
+ Member member = guild.getMemberById(player.getUserId());
+ if (member != null) {
+ playerJson.put("name", player.getNickname());
+ playerJson.put("username", member.getUser().getName());
+ playerJson.put("avatar", member.getEffectiveAvatarUrl());
+
+ boolean isJudge = member.getRoles().stream()
+ .anyMatch(r -> r.getIdLong() == session.getJudgeRoleId());
+ playerJson.put("isJudge", isJudge);
+
+ foundMember = true;
+ }
+ }
+ }
+ if (!foundMember) {
+ playerJson.put("name", player.getNickname());
+ playerJson.put("username", null);
+ playerJson.put("avatar", null);
+ playerJson.put("isJudge", false);
+ }
+
+ players.add(playerJson);
+ }
+
+ players.sort((a, b) -> {
+ int idA = Integer.parseInt((String) a.get("id"));
+ int idB = Integer.parseInt((String) b.get("id"));
+ return Integer.compare(idA, idB);
+ });
+
+ return players;
+ }
+
+ @Override
+ public Map sessionToSummaryJSON(Session session) {
+ java.util.Map summary = new java.util.LinkedHashMap<>();
+ summary.put("guildId", String.valueOf(session.getGuildId()));
+
+ String guildName = "Unknown Server";
+ String guildIcon = null;
+ try {
+ net.dv8tion.jda.api.entities.Guild guild = discordService.getGuild(session.getGuildId());
+ if (guild != null) {
+ guildName = guild.getName();
+ guildIcon = guild.getIconUrl();
+ }
+ } catch (Exception e) {
+ log.warn("Failed to fetch guild info for summary: {}", session.getGuildId());
+ }
+
+ summary.put("guildName", guildName);
+ summary.put("guildIcon", guildIcon);
+
+ int pCount = session.getPlayers().size();
+ summary.put("playerCount", pCount);
+ log.info("Summary for guild {}: name='{}', players={}", session.getGuildId(), guildName, pCount);
+
+ return summary;
+ }
+
+ @Override
+ public List> getGuildMembers(long guildId) throws Exception {
+ Session session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new Exception("Session not found"));
+
+ var jda = discordService.getJDA();
+ if (jda == null) {
+ throw new Exception("JDA instance is required");
+ }
+
+ Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ throw new Exception("Guild not found");
+ }
+
+ List> membersJson = new java.util.ArrayList<>();
+
+ for (Member member : guild.getMembers()) {
+ if (member.getUser().isBot())
+ continue;
+
+ Map memberMap = new java.util.LinkedHashMap<>();
+ memberMap.put("userId", String.valueOf(member.getIdLong()));
+ memberMap.put("username", member.getUser().getName());
+ memberMap.put("name", member.getEffectiveName());
+ memberMap.put("avatar", member.getEffectiveAvatarUrl());
+
+ boolean isJudge = member.getRoles().stream()
+ .anyMatch(r -> r.getIdLong() == session.getJudgeRoleId());
+ memberMap.put("isJudge", isJudge);
+
+ boolean isPlayer = session.getPlayers().values().stream()
+ .anyMatch(p -> p.getUserId() != null && p.getUserId() == member.getIdLong() && p.isAlive());
+ memberMap.put("isPlayer", isPlayer);
+
+ membersJson.add(memberMap);
+ }
+
+ membersJson.sort((a, b) -> {
+ boolean judgeA = (boolean) a.get("isJudge");
+ boolean judgeB = (boolean) b.get("isJudge");
+ if (judgeA != judgeB)
+ return judgeB ? 1 : -1;
+ return ((String) a.get("name")).compareTo((String) b.get("name"));
+ });
+
+ return membersJson;
+ }
+
+ @Override
+ public void updateUserRole(long guildId, long userId, UserRole role)
+ throws Exception {
+ Session session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new Exception("Session not found"));
+
+ var jda = discordService.getJDA();
+ if (jda == null) {
+ throw new Exception("JDA instance is required");
+ }
+
+ Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ throw new Exception("Guild not found");
+ }
+
+ Member member = guild.getMemberById(userId);
+ if (member == null) {
+ member = guild.retrieveMemberById(userId).complete();
+ }
+
+ net.dv8tion.jda.api.entities.Role judgeRole = guild.getRoleById(session.getJudgeRoleId());
+ if (judgeRole == null) {
+ throw new Exception("Judge role not configured or found in guild");
+ }
+
+ if (role == UserRole.JUDGE) {
+ guild.addRoleToMember(member, judgeRole).complete();
+ } else if (role == UserRole.SPECTATOR) {
+ guild.removeRoleFromMember(member, judgeRole).complete();
+
+ net.dv8tion.jda.api.entities.Role spectatorRole = guild.getRoleById(session.getSpectatorRoleId());
+ if (spectatorRole != null) {
+ guild.addRoleToMember(member, spectatorRole).complete();
+ }
+ } else {
+ throw new Exception("Unsupported role update: " + role);
+ }
+ }
+
+ @Override
+ public void updateSettings(long guildId, Map settings) throws Exception {
+ Session session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new Exception("Session not found"));
+
+ if (settings.containsKey("doubleIdentities")) {
+ session.setDoubleIdentities((Boolean) settings.get("doubleIdentities"));
+ }
+ if (settings.containsKey("muteAfterSpeech")) {
+ session.setMuteAfterSpeech((Boolean) settings.get("muteAfterSpeech"));
+ }
+
+ saveSession(session);
+ broadcastSessionUpdate(session);
+ }
+
+ @Override
+ public void broadcastUpdate(long guildId) {
+ Optional sessionOpt = getSession(guildId);
+ if (sessionOpt.isPresent()) {
+ Map updateData = sessionToJSON(sessionOpt.get());
+ broadcastEvent("UPDATE", updateData);
+ }
+ }
+
+ @Override
+ public void broadcastSessionUpdate(Session session) {
+ if (session != null) {
+ broadcastUpdate(session.getGuildId());
+ }
+ }
+
+ @Override
+ public void broadcastEvent(String type, Map data) {
+ try {
+ // Using jackson to serialize would be better, but for now simple string
+ // construction or JSON utils
+ // Assuming data is JSON-compatible map?
+ // Since we need to send JSON string.
+ // Using a simple JSON serialization via standard libs or Jackson if available
+ // (Spring Boot has Jackson).
+ // Let's rely on standard toString() if it produces valid JSON? No.
+ // Map.toString() isn't JSON.
+ // We need ObjectMapper.
+
+ com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
+ // Wrap in event structure
+ // We broadcast the event in an envelope: { "type": type, "data": data }
+ // Let's assume we just broadcast the raw JSON object or an envelope.
+ // Assuming envelope: { "type": type, "data": data }
+
+ Map envelope = Map.of("type", type, "data", data);
+ String jsonMessage = mapper.writeValueAsString(envelope);
+
+ webSocketHandler.broadcast(jsonMessage);
+ } catch (Exception e) {
+ log.error("Failed to broadcast event", e);
+ }
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/impl/PlayerServiceImpl.java b/src/main/java/dev/robothanzo/werewolf/service/impl/PlayerServiceImpl.java
new file mode 100644
index 0000000..f919aad
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/impl/PlayerServiceImpl.java
@@ -0,0 +1,247 @@
+package dev.robothanzo.werewolf.service.impl;
+
+import dev.robothanzo.werewolf.database.documents.Session;
+import dev.robothanzo.werewolf.security.SessionRepository;
+import dev.robothanzo.werewolf.service.DiscordService;
+import dev.robothanzo.werewolf.service.GameSessionService;
+import dev.robothanzo.werewolf.service.PlayerService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class PlayerServiceImpl implements PlayerService {
+
+ private final SessionRepository sessionRepository;
+ private final DiscordService discordService;
+ private final GameSessionService gameSessionService;
+
+ @Override
+ public List> getPlayersJSON(long guildId) {
+ Optional sessionOpt = sessionRepository.findByGuildId(guildId);
+ if (sessionOpt.isEmpty()) {
+ throw new RuntimeException("Session not found");
+ }
+ return gameSessionService.playersToJSON(sessionOpt.get());
+ }
+
+ @Override
+ public void setPlayerCount(long guildId, int count, java.util.function.Consumer onProgress,
+ java.util.function.Consumer onPercent) throws Exception {
+ var session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new RuntimeException("Session not found"));
+
+ var jda = discordService.getJDA();
+ var guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ throw new Exception("Guild not found");
+ }
+
+ if (onPercent != null)
+ onPercent.accept(0);
+ if (onProgress != null)
+ onProgress.accept("開始同步 Discord 狀態...");
+
+ Map players = session.getPlayers();
+ List deleteTasks = new ArrayList<>();
+ List createRoleTasks = new ArrayList<>();
+ List createChannelTasks = new ArrayList<>();
+
+ // Phase 1: Identify deletions
+ var existingPlayerIds = new java.util.LinkedList<>(players.keySet());
+ for (String idStr : existingPlayerIds) {
+ int pid = Integer.parseInt(idStr);
+ if (pid > count) {
+ var player = players.remove(idStr);
+ var role = guild.getRoleById(player.getRoleId());
+ if (role != null) {
+ deleteTasks.add(new dev.robothanzo.werewolf.utils.DiscordActionRunner.ActionTask(role.delete(),
+ "刪除身分組: " + role.getName()));
+ }
+ var channel = guild.getTextChannelById(player.getChannelId());
+ if (channel != null) {
+ deleteTasks.add(new dev.robothanzo.werewolf.utils.DiscordActionRunner.ActionTask(channel.delete(),
+ "刪除頻道: " + channel.getName()));
+ }
+ }
+ }
+
+ // Run Deletions (0% -> 30%)
+ if (!deleteTasks.isEmpty()) {
+ dev.robothanzo.werewolf.utils.DiscordActionRunner.runActions(deleteTasks, onProgress, onPercent, 0, 30, 60);
+ }
+
+ // Phase 2: Create Roles
+ var spectatorRole = guild.getRoleById(session.getSpectatorRoleId());
+ Map newRolesMap = new java.util.concurrent.ConcurrentHashMap<>();
+
+ for (int i = players.size() + 1; i <= count; i++) {
+ int playerId = i;
+ String name = "玩家" + dev.robothanzo.werewolf.database.documents.Session.Player.ID_FORMAT.format(i);
+ var task = new dev.robothanzo.werewolf.utils.DiscordActionRunner.ActionTask(
+ guild.createRole()
+ .setColor(dev.robothanzo.werewolf.utils.MsgUtils.getRandomColor())
+ .setHoisted(true)
+ .setName(name),
+ "創建身分組: " + name);
+ task.onSuccess = (obj) -> newRolesMap.put(playerId, (net.dv8tion.jda.api.entities.Role) obj);
+ createRoleTasks.add(task);
+ }
+
+ if (!createRoleTasks.isEmpty()) {
+ dev.robothanzo.werewolf.utils.DiscordActionRunner.runActions(createRoleTasks, onProgress, onPercent, 30, 60,
+ 60);
+ }
+
+ // Phase 3: Create Channels
+ for (var entry : newRolesMap.entrySet()) {
+ int playerId = entry.getKey();
+ var role = entry.getValue();
+ String name = "玩家" + dev.robothanzo.werewolf.database.documents.Session.Player.ID_FORMAT.format(playerId);
+
+ var task = new dev.robothanzo.werewolf.utils.DiscordActionRunner.ActionTask(
+ guild.createTextChannel(name)
+ .addPermissionOverride(spectatorRole != null ? spectatorRole : guild.getPublicRole(),
+ net.dv8tion.jda.api.Permission.VIEW_CHANNEL.getRawValue(),
+ net.dv8tion.jda.api.Permission.MESSAGE_SEND.getRawValue())
+ .addPermissionOverride(role,
+ List.of(net.dv8tion.jda.api.Permission.VIEW_CHANNEL,
+ net.dv8tion.jda.api.Permission.MESSAGE_SEND),
+ List.of())
+ .addPermissionOverride(guild.getPublicRole(),
+ List.of(),
+ List.of(net.dv8tion.jda.api.Permission.VIEW_CHANNEL,
+ net.dv8tion.jda.api.Permission.MESSAGE_SEND,
+ net.dv8tion.jda.api.Permission.USE_APPLICATION_COMMANDS)),
+ "創建頻道: " + name);
+
+ task.onSuccess = (obj) -> {
+ var channel = (net.dv8tion.jda.api.entities.channel.concrete.TextChannel) obj;
+ players.put(String.valueOf(playerId),
+ dev.robothanzo.werewolf.database.documents.Session.Player.builder()
+ .id(playerId)
+ .roleId(role.getIdLong())
+ .channelId(channel.getIdLong())
+ .build());
+ };
+ createChannelTasks.add(task);
+ }
+
+ if (!createChannelTasks.isEmpty()) {
+ dev.robothanzo.werewolf.utils.DiscordActionRunner.runActions(createChannelTasks, onProgress, onPercent, 60,
+ 95, 120);
+ }
+
+ session.setPlayers(players);
+ sessionRepository.save(session);
+ if (onPercent != null)
+ onPercent.accept(100);
+ if (onProgress != null)
+ onProgress.accept("同步完成!");
+ gameSessionService.broadcastUpdate(guildId);
+ }
+
+ @Override
+ public void updatePlayerRoles(long guildId, String playerId, List roles) {
+ try {
+ var session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new RuntimeException("Session not found"));
+ var player = session.getPlayers().get(playerId);
+ if (player == null)
+ throw new Exception("Player not found");
+
+ List finalRoles = new ArrayList<>(roles);
+ boolean isDuplicated = roles.contains("複製人");
+ player.setDuplicated(isDuplicated);
+ if (isDuplicated && finalRoles.size() == 2) {
+ if (finalRoles.get(0).equals("複製人"))
+ finalRoles.set(0, finalRoles.get(1));
+ else if (finalRoles.get(1).equals("複製人"))
+ finalRoles.set(1, finalRoles.get(0));
+ }
+
+ player.setIdiot(finalRoles.contains("白癡"));
+ boolean isJinBaoBao = session.isDoubleIdentities() && finalRoles.size() == 2 &&
+ finalRoles.get(0).equals("平民") && finalRoles.get(1).equals("平民");
+ player.setJinBaoBao(isJinBaoBao);
+ player.setRoles(finalRoles);
+
+ sessionRepository.save(session);
+
+ var jda = discordService.getJDA();
+ if (jda != null && player.getChannelId() != 0) {
+ var guild = jda.getGuildById(guildId);
+ if (guild != null) {
+ var channel = guild.getTextChannelById(player.getChannelId());
+ if (channel != null) {
+ channel.sendMessage("法官已將你的身份更改為: " + String.join(", ", roles)).queue();
+ }
+ }
+ }
+
+ gameSessionService.broadcastUpdate(guildId);
+ } catch (Exception e) {
+ log.error("Failed to update player roles: {}", e.getMessage(), e);
+ throw new RuntimeException("Failed to update player roles", e);
+ }
+ }
+
+ @Override
+ public void switchRoleOrder(long guildId, String playerId) {
+ try {
+ var session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new RuntimeException("Session not found"));
+ var player = session.getPlayers().get(playerId);
+ if (player == null)
+ throw new Exception("Player not found");
+
+ if (player.isRolePositionLocked())
+ throw new Exception("你的身分順序已被鎖定");
+ if (player.getRoles() == null || player.getRoles().size() < 2)
+ throw new Exception("Not enough roles to switch");
+
+ java.util.Collections.swap(player.getRoles(), 0, 1);
+ sessionRepository.save(session);
+
+ var jda = discordService.getJDA();
+ if (jda != null && player.getChannelId() != 0) {
+ var guild = jda.getGuildById(guildId);
+ if (guild != null) {
+ var channel = guild.getTextChannelById(player.getChannelId());
+ if (channel != null) {
+ channel.sendMessage("你已交換了角色順序,現在主要角色為: " + player.getRoles().get(0)).queue();
+ }
+ }
+ }
+ gameSessionService.broadcastUpdate(guildId);
+ } catch (Exception e) {
+ log.error("Switch role order failed: {}", e.getMessage(), e);
+ throw new RuntimeException("Failed to switch role order", e);
+ }
+ }
+
+ @Override
+ public void setRolePositionLock(long guildId, String playerId, boolean locked) {
+ try {
+ var session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new RuntimeException("Session not found"));
+ var player = session.getPlayers().get(playerId);
+ if (player == null)
+ throw new Exception("Player not found");
+
+ player.setRolePositionLocked(locked);
+ sessionRepository.save(session);
+ gameSessionService.broadcastUpdate(guildId);
+ } catch (Exception e) {
+ log.error("Failed to set role position lock: {}", e.getMessage(), e);
+ throw new RuntimeException("Failed to set role position lock", e);
+ }
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/impl/PoliceServiceImpl.java b/src/main/java/dev/robothanzo/werewolf/service/impl/PoliceServiceImpl.java
new file mode 100644
index 0000000..cecdea6
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/impl/PoliceServiceImpl.java
@@ -0,0 +1,380 @@
+package dev.robothanzo.werewolf.service.impl;
+
+import dev.robothanzo.werewolf.audio.Audio;
+import dev.robothanzo.werewolf.commands.Poll;
+import dev.robothanzo.werewolf.database.documents.Session;
+import dev.robothanzo.werewolf.model.Candidate;
+import dev.robothanzo.werewolf.model.PoliceSession;
+import dev.robothanzo.werewolf.security.SessionRepository;
+import dev.robothanzo.werewolf.service.DiscordService;
+import dev.robothanzo.werewolf.service.GameSessionService;
+import dev.robothanzo.werewolf.service.PoliceService;
+import dev.robothanzo.werewolf.utils.CmdUtils;
+import dev.robothanzo.werewolf.utils.MsgUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import net.dv8tion.jda.api.EmbedBuilder;
+import net.dv8tion.jda.api.components.actionrow.ActionRow;
+import net.dv8tion.jda.api.components.buttons.Button;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.Message;
+import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
+import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class PoliceServiceImpl implements PoliceService {
+
+ private final SessionRepository sessionRepository;
+ private final DiscordService discordService;
+ private final GameSessionService gameSessionService;
+ private final dev.robothanzo.werewolf.service.SpeechService speechService;
+
+ private final Map sessions = new ConcurrentHashMap<>();
+
+ @Override
+ public Map getSessions() {
+ return sessions;
+ }
+
+ @Override
+ public void startEnrollment(Session session, GuildMessageChannel channel, Message message) {
+ if (sessions.containsKey(session.getGuildId()))
+ return;
+
+ PoliceSession policeSession = PoliceSession.builder()
+ .guildId(session.getGuildId())
+ .channelId(channel.getIdLong())
+ .session(session)
+ .build();
+ sessions.put(session.getGuildId(), policeSession);
+ next(session.getGuildId());
+ }
+
+ @Override
+ public void enrollPolice(ButtonInteractionEvent event) {
+ event.deferReply(true).queue();
+ Session session = CmdUtils.getSession(Objects.requireNonNull(event.getGuild()));
+ if (session == null)
+ return;
+
+ PoliceSession policeSession = sessions.get(event.getGuild().getIdLong());
+ if (policeSession == null) {
+ event.getHook().editOriginal(":x: 無法參選,時間已到").queue();
+ return;
+ }
+
+ for (Map.Entry candidate : new LinkedList<>(policeSession.getCandidates().entrySet())) {
+ if (Objects.equals(event.getUser().getIdLong(), candidate.getValue().getPlayer().getUserId())) {
+ if (policeSession.getState().canEnroll()) { // ENROLLMENT -> Remove completely
+ policeSession.getCandidates().remove(candidate.getKey());
+ event.getHook().editOriginal(":white_check_mark: 已取消參選").queue();
+
+ Map metadata = new HashMap<>();
+ metadata.put("playerId", candidate.getValue().getPlayer().getId());
+ metadata.put("playerName", candidate.getValue().getPlayer().getNickname());
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.POLICE_UNENROLLED,
+ candidate.getValue().getPlayer().getNickname() + " 已取消參選警長", metadata);
+ gameSessionService.broadcastSessionUpdate(session);
+
+ } else if (policeSession.getState().canQuit()) { // UNENROLLMENT -> Mark quit
+ policeSession.getCandidates().get(candidate.getKey()).setQuit(true);
+ event.getHook().editOriginal(":white_check_mark: 已取消參選").queue();
+ Objects.requireNonNull(event.getGuild().getTextChannelById(session.getCourtTextChannelId()))
+ .sendMessage(event.getUser().getAsMention() + " 已取消參選").queue();
+
+ Map metadata = new HashMap<>();
+ metadata.put("playerId", candidate.getValue().getPlayer().getId());
+ metadata.put("playerName", candidate.getValue().getPlayer().getNickname());
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.POLICE_UNENROLLED,
+ candidate.getValue().getPlayer().getNickname() + " 已取消參選警長", metadata);
+ gameSessionService.broadcastSessionUpdate(session);
+ } else {
+ event.getHook().editOriginal(":x: 無法取消參選,投票已開始").queue();
+ }
+ return;
+ }
+ }
+
+ if (!policeSession.getState().canEnroll()) {
+ event.getHook().editOriginal(":x: 無法參選,時間已到").queue();
+ return;
+ }
+
+ for (Session.Player player : session.fetchAlivePlayers().values()) {
+ if (Objects.equals(event.getUser().getIdLong(), player.getUserId())) {
+ policeSession.getCandidates().put(player.getId(), Candidate.builder().player(player).build());
+ event.getHook().editOriginal(":white_check_mark: 已參選").queue();
+
+ java.util.Map metadata = new java.util.HashMap<>();
+ metadata.put("playerId", player.getId());
+ metadata.put("playerName", player.getNickname());
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.POLICE_ENROLLED,
+ player.getNickname() + " 已參選警長", metadata);
+
+ gameSessionService.broadcastSessionUpdate(session);
+ return;
+ }
+ }
+ event.getHook().editOriginal(":x: 你不是玩家").queue();
+ }
+
+ @Override
+ public void next(long guildId) {
+ PoliceSession policeSession = sessions.get(guildId);
+ if (policeSession == null)
+ return;
+
+ Guild guild = discordService.getJDA().getGuildById(guildId);
+ if (guild == null) {
+ interrupt(guildId);
+ return;
+ }
+ var channel = guild.getTextChannelById(policeSession.getChannelId());
+ if (channel == null) {
+ interrupt(guildId);
+ return;
+ }
+
+ switch (policeSession.getState()) {
+ case NONE -> {
+ policeSession.setState(PoliceSession.State.ENROLLMENT);
+ policeSession.setStageEndTime(System.currentTimeMillis() + 30000);
+ policeSession.getCandidates().clear();
+
+ policeSession.getSession().addLog(
+ dev.robothanzo.werewolf.database.documents.LogType.POLICE_ENROLLMENT_STARTED,
+ "警長參選已開始", null);
+ gameSessionService.broadcastSessionUpdate(policeSession.getSession());
+
+ Audio.play(Audio.Resource.POLICE_ENROLL,
+ guild.getVoiceChannelById(policeSession.getSession().getCourtVoiceChannelId()));
+ EmbedBuilder embed = new EmbedBuilder()
+ .setTitle("參選警長").setDescription("30秒後立刻進入辯論,請加快手速!")
+ .setColor(MsgUtils.getRandomColor());
+
+ channel.sendMessageEmbeds(embed.build())
+ .setComponents(ActionRow.of(Button.success("enrollPolice", "參選警長")))
+ .queue(msg -> policeSession.setMessage(msg));
+
+ CmdUtils.schedule(() -> Audio.play(Audio.Resource.ENROLL_10S_REMAINING,
+ guild.getVoiceChannelById(policeSession.getSession().getCourtVoiceChannelId())), 20000);
+ CmdUtils.schedule(() -> next(guildId), 30000);
+ }
+ case ENROLLMENT -> {
+ if (policeSession.getCandidates().isEmpty()) {
+ if (policeSession.getMessage() != null)
+ policeSession.getMessage().reply("無人參選,警徽撕毀").queue();
+ interrupt(guildId);
+ return;
+ }
+
+ List candidateMentions = new LinkedList<>();
+ for (Candidate candidate : policeSession.getCandidates().values().stream()
+ .sorted(Candidate.getComparator())
+ .toList()) {
+ candidateMentions.add("<@!" + candidate.getPlayer().getUserId() + ">");
+ }
+
+ if (policeSession.getCandidates().size() == 1) {
+ if (policeSession.getMessage() != null)
+ policeSession.getMessage().reply("只有" + candidateMentions.getFirst() + "參選,直接當選").queue();
+ setPolice(policeSession.getSession(), policeSession.getCandidates().values().iterator().next(),
+ channel);
+ interrupt(guildId);
+ return;
+ }
+
+ if (policeSession.getMessage() != null) {
+ policeSession.getMessage().replyEmbeds(new EmbedBuilder().setTitle("參選警長結束")
+ .setDescription(
+ "參選的有: " + String.join("、", candidateMentions) + "\n備註:你可隨時再按一次按鈕以取消參選")
+ .setColor(MsgUtils.getRandomColor()).build()).queue();
+ }
+
+ policeSession.setState(PoliceSession.State.SPEECH);
+ gameSessionService.broadcastSessionUpdate(policeSession.getSession());
+
+ // Start speech
+ // Start speech
+ speechService.startSpeechPoll(guild, policeSession.getMessage(),
+ policeSession.getCandidates().values().stream().map(Candidate::getPlayer).toList(),
+ () -> next(guildId));
+ }
+ case SPEECH -> {
+ policeSession.setState(PoliceSession.State.UNENROLLMENT);
+ policeSession.setStageEndTime(System.currentTimeMillis() + 20000);
+ gameSessionService.broadcastSessionUpdate(policeSession.getSession());
+
+ if (policeSession.getMessage() != null) {
+ policeSession.getMessage().getChannel().sendMessage("政見發表結束,參選人有20秒進行退選,20秒後將自動開始投票").queue();
+ }
+
+ CmdUtils.schedule(() -> next(guildId), 20000);
+ }
+ case UNENROLLMENT -> {
+ policeSession.setState(PoliceSession.State.VOTING);
+ policeSession.setStageEndTime(System.currentTimeMillis() + 30000);
+
+ if (policeSession.getCandidates().values().stream().allMatch(Candidate::isQuit)) {
+ if (policeSession.getMessage() != null)
+ policeSession.getMessage().reply("所有人退選,警徽撕毀").queue();
+ interrupt(guildId);
+ return;
+ }
+
+ gameSessionService.broadcastSessionUpdate(policeSession.getSession());
+ startVoting(channel, false, policeSession);
+ }
+ case VOTING, FINISHED -> {
+ // Logic handled in startVoting callback
+ }
+ }
+ }
+
+ @Override
+ public void interrupt(long guildId) {
+ PoliceSession policeSession = sessions.remove(guildId);
+ if (policeSession != null) {
+ gameSessionService.broadcastSessionUpdate(policeSession.getSession());
+ }
+ }
+
+ @Override
+ public void forceStartVoting(long guildId) {
+ PoliceSession policeSession = sessions.get(guildId);
+ if (policeSession != null) {
+ policeSession.setState(PoliceSession.State.UNENROLLMENT); // trick next() to go to VOTING
+ next(guildId);
+ }
+ }
+
+ private void startVoting(GuildMessageChannel channel, boolean allowPK, PoliceSession policeSession) {
+ Audio.play(Audio.Resource.POLICE_POLL,
+ channel.getGuild().getVoiceChannelById(policeSession.getSession().getCourtVoiceChannelId()));
+ EmbedBuilder embedBuilder = new EmbedBuilder().setTitle("警長投票")
+ .setDescription("30秒後立刻計票,請加快手速!\n若要改票可直接按下要改成的對象\n若要改為棄票需按下原本投給的使用者")
+ .setColor(MsgUtils.getRandomColor());
+
+ List buttons = new LinkedList<>();
+ for (Candidate player : policeSession.getCandidates().values().stream().sorted(Candidate.getComparator())
+ .toList()) {
+ if (player.isQuit())
+ continue;
+ Member user = channel.getGuild().getMemberById(player.getPlayer().getUserId());
+ if (user != null) {
+ buttons.add(Button.primary("votePolice" + player.getPlayer().getId(),
+ player.getPlayer().getNickname() + " (" + user.getUser().getName() + ")"));
+ }
+ }
+
+ channel.sendMessageEmbeds(embedBuilder.build())
+ .setComponents(MsgUtils.spreadButtonsAcrossActionRows(buttons).toArray(new ActionRow[0]))
+ .queue();
+
+ CmdUtils.schedule(() -> Audio.play(Audio.Resource.POLL_10S_REMAINING,
+ channel.getGuild().getVoiceChannelById(policeSession.getSession().getCourtVoiceChannelId())), 20000);
+ CmdUtils.schedule(() -> {
+ finishVoting(channel, allowPK, policeSession);
+ }, 30000);
+ }
+
+ private void finishVoting(GuildMessageChannel channel, boolean allowPK, PoliceSession policeSession) {
+ List winners = Candidate.getWinner(policeSession.getCandidates().values(), null);
+ if (winners.isEmpty()) {
+ if (policeSession.getMessage() != null)
+ policeSession.getMessage().reply("沒有人投票,警徽撕毀").queue();
+ interrupt(policeSession.getGuildId());
+ return;
+ }
+
+ if (winners.size() == 1) {
+ Candidate winner = winners.getFirst();
+ if (policeSession.getMessage() != null)
+ policeSession.getMessage().reply("投票已結束,<@!" + winner.getPlayer().getUserId() + "> 獲勝").queue();
+
+ EmbedBuilder resultEmbed = new EmbedBuilder().setTitle("警長投票").setColor(MsgUtils.getRandomColor())
+ .setDescription("獲勝玩家: <@!" + winner.getPlayer().getUserId() + ">");
+
+ Map> wrapper = new HashMap<>();
+ wrapper.put(channel.getGuild().getIdLong(), policeSession.getCandidates());
+ Poll.sendVoteResult(policeSession.getSession(), channel, policeSession.getMessage(), resultEmbed, wrapper,
+ true);
+
+ setPolice(policeSession.getSession(), winner, channel);
+ interrupt(policeSession.getGuildId());
+ } else {
+ if (allowPK) {
+ EmbedBuilder resultEmbed = new EmbedBuilder().setTitle("警長投票")
+ .setColor(MsgUtils.getRandomColor())
+ .setDescription("發生平票");
+ Map> wrapper = new HashMap<>();
+ wrapper.put(channel.getGuild().getIdLong(), policeSession.getCandidates());
+ Poll.sendVoteResult(policeSession.getSession(), channel, policeSession.getMessage(), resultEmbed,
+ wrapper, true);
+
+ handlePK(channel, winners, policeSession);
+ } else {
+ EmbedBuilder resultEmbed = new EmbedBuilder().setTitle("警長投票")
+ .setColor(MsgUtils.getRandomColor())
+ .setDescription("平票第二次,警徽撕毀");
+ if (policeSession.getMessage() != null)
+ policeSession.getMessage().reply("平票第二次,警徽撕毀").queue();
+ Map> wrapper = new HashMap<>();
+ wrapper.put(channel.getGuild().getIdLong(), policeSession.getCandidates());
+ Poll.sendVoteResult(policeSession.getSession(), channel, policeSession.getMessage(), resultEmbed,
+ wrapper, true);
+ interrupt(policeSession.getGuildId());
+ }
+ }
+ }
+
+ private void handlePK(GuildMessageChannel channel, List winners, PoliceSession policeSession) {
+ if (policeSession.getMessage() != null)
+ policeSession.getMessage().reply("平票,請PK").queue();
+
+ // Clear votes and reset candidates to only winners
+ Map newCandidates = new ConcurrentHashMap<>();
+ for (Candidate winner : winners) {
+ winner.getElectors().clear();
+ newCandidates.put(winner.getPlayer().getId(), winner);
+ }
+ policeSession.setCandidates(newCandidates);
+
+ speechService.startSpeechPoll(channel.getGuild(), policeSession.getMessage(),
+ newCandidates.values().stream().map(Candidate::getPlayer).toList(),
+ () -> {
+ policeSession.setState(PoliceSession.State.VOTING);
+ policeSession.setStageEndTime(System.currentTimeMillis() + 30000);
+ gameSessionService.broadcastSessionUpdate(policeSession.getSession());
+ startVoting(channel, false, policeSession);
+ });
+ }
+
+ private void setPolice(Session session, Candidate winner, GuildMessageChannel channel) {
+ Member member = channel.getGuild()
+ .getMemberById(Objects.requireNonNull(winner.getPlayer().getUserId()));
+ if (member != null)
+ member.modifyNickname(member.getEffectiveName() + " [警長]").queue();
+
+ Session.Player p = session.getPlayers().get(String.valueOf(winner.getPlayer().getId()));
+ if (p != null) {
+ p.setPolice(true);
+ }
+ sessionRepository.save(session);
+
+ Map metadata = new HashMap<>();
+ metadata.put("playerId", winner.getPlayer().getId());
+ metadata.put("playerName", winner.getPlayer().getNickname());
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.POLICE_ELECTED,
+ winner.getPlayer().getNickname() + " 當選警長", metadata);
+
+ gameSessionService.broadcastSessionUpdate(session);
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/service/impl/RoleServiceImpl.java b/src/main/java/dev/robothanzo/werewolf/service/impl/RoleServiceImpl.java
new file mode 100644
index 0000000..57f41bf
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/service/impl/RoleServiceImpl.java
@@ -0,0 +1,312 @@
+package dev.robothanzo.werewolf.service.impl;
+
+import dev.robothanzo.werewolf.security.SessionRepository;
+import dev.robothanzo.werewolf.service.DiscordService;
+import dev.robothanzo.werewolf.service.GameSessionService;
+import dev.robothanzo.werewolf.service.RoleService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import net.dv8tion.jda.api.EmbedBuilder;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.function.Consumer;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RoleServiceImpl implements RoleService {
+
+ private final SessionRepository sessionRepository;
+ private final DiscordService discordService;
+ private final GameSessionService gameSessionService;
+
+ @Override
+ public void addRole(long guildId, String roleName, int amount) {
+ try {
+ var session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new RuntimeException("Session not found"));
+
+ List roles = new java.util.ArrayList<>(session.getRoles());
+ for (int i = 0; i < amount; i++) {
+ roles.add(roleName);
+ }
+ session.setRoles(roles);
+ sessionRepository.save(session);
+
+ gameSessionService.broadcastUpdate(guildId);
+ } catch (Exception e) {
+ log.error("Failed to add role: {}", e.getMessage(), e);
+ throw new RuntimeException("Failed to add role", e);
+ }
+ }
+
+ @Override
+ public void removeRole(long guildId, String roleName, int amount) {
+ try {
+ var session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new RuntimeException("Session not found"));
+
+ List roles = new java.util.ArrayList<>(session.getRoles());
+ for (int i = 0; i < amount; i++) {
+ roles.remove(roleName);
+ }
+ session.setRoles(roles);
+ sessionRepository.save(session);
+
+ gameSessionService.broadcastUpdate(guildId);
+ } catch (Exception e) {
+ log.error("Failed to remove role: {}", e.getMessage(), e);
+ throw new RuntimeException("Failed to remove role", e);
+ }
+ }
+
+ @Override
+ public List getRoles(long guildId) {
+ var session = sessionRepository.findByGuildId(guildId)
+ .orElseThrow(() -> new RuntimeException("Session not found"));
+ return session.getRoles();
+ }
+
+ @Override
+ public void assignRoles(long guildId, Consumer