-
-
-
-
-
- {t('players.title')} ({gameState.players.filter(p => p.isAlive).length} {t('players.alive')})
-
-
-
- {gameState.players.map(player => (
))}
-
-
-
{t('globalCommands.title')}
-
- handleGlobalAction('broadcast_role')} 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-3 py-2 rounded border border-slate-400 dark:border-slate-700">{t('globalCommands.broadcastRole')}
- handleGlobalAction('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-3 py-2 rounded border border-slate-400 dark:border-slate-700">{t('globalCommands.randomAssign')}
- handleGlobalAction('reset')} className="text-xs 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 px-3 py-2 rounded border border-red-300 dark:border-red-900/30">{t('globalCommands.forceReset')}
-
+
+
+ {/* Main Content Area */}
+
+
+
+
+
+
+
+ {t('players.title')} ({gameState.players.filter(p => p.isAlive).length} {t('players.alive')})
+
+
+
+ {gameState.players.map(player => (
+
+ ))}
+
+ >
+ } />
+
+ } />
+
+ } />
+
+ } />
+
+
+ {/* Mobile Game Log */}
+
+
-
setGameState(prev => ({ ...prev, logs: [] }))} onManualCommand={(cmd) => addLog(t('gameLog.manualCommand', { cmd }))} />
+
+
+ {/* Desktop Right Sidebar Game Log */}
+
+
- {showIntegrationGuide &&
setShowIntegrationGuide(false)} />}
+ {showSettings && setShowSettings(false)} />}
+ {editingPlayerId && editingPlayer && guildId && (
+ setEditingPlayerId(null)}
+ doubleIdentities={gameState.doubleIdentities}
+ availableRoles={gameState.availableRoles || []}
+ />
+ )}
+ {deathConfirmPlayerId && guildId && (
+ p.id === deathConfirmPlayerId)!}
+ guildId={guildId}
+ onClose={() => setDeathConfirmPlayerId(null)}
+ />
+ )}
+
+ setOverlayVisible(false)}
+ />
+
+ {showTimerModal && (
+ setShowTimerModal(false)}
+ onStart={handleTimerStart}
+ />
+ )}
+
+ {playerSelectModal.visible && (
+ setPlayerSelectModal({ ...playerSelectModal, visible: false, customPlayers: undefined })}
+ onSelect={handlePlayerSelect}
+ filter={(p) => {
+ // Filtering logic based on user roles and requirements
+ if (!p.userId) return false; // Must be a real user
+
+ if (playerSelectModal.type === 'ASSIGN_JUDGE') {
+ return !p.isJudge;
+ }
+ if (playerSelectModal.type === 'DEMOTE_JUDGE') {
+ return !!p.isJudge;
+ }
+ if (playerSelectModal.type === 'FORCE_POLICE') {
+ // Should only show players who are alive? or just all players?
+ // Usually force police is for alive players.
+ return p.isAlive;
+ }
+ return true;
+ }}
+ />
+ )}
-
+
+ );
+};
+
+const LoginPage = () => {
+ const handleLogin = () => {
+ // Redirect to OAuth login (no guild_id yet)
+ window.location.href = '/api/auth/login';
+ };
+ return
;
+};
+
+const ServerSelectionPage = () => {
+ const navigate = useNavigate();
+ const { user, loading } = useAuth();
+
+ // Redirect to login if not authenticated
+ useEffect(() => {
+ if (!loading && !user) {
+ navigate('/login');
+ }
+ }, [user, loading, navigate]);
+
+ // Show loading while checking auth
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!user) {
+ return null;
+ }
+
+ const handleSelectServer = (guildId: string) => {
+ navigate(`/server/${guildId}`);
+ };
+ return
navigate('/login')} />;
+};
+
+const App = () => {
+ return (
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
);
};
diff --git a/src/dashboard/src/components/AccessDenied.tsx b/src/dashboard/src/components/AccessDenied.tsx
new file mode 100644
index 0000000..479aac5
--- /dev/null
+++ b/src/dashboard/src/components/AccessDenied.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { ShieldAlert, ArrowLeft } 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/components/AuthCallback.tsx b/src/dashboard/src/components/AuthCallback.tsx
new file mode 100644
index 0000000..55cb230
--- /dev/null
+++ b/src/dashboard/src/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 '../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/components/DeathConfirmModal.tsx b/src/dashboard/src/components/DeathConfirmModal.tsx
new file mode 100644
index 0000000..6bf5c65
--- /dev/null
+++ b/src/dashboard/src/components/DeathConfirmModal.tsx
@@ -0,0 +1,99 @@
+import React, { useState } from 'react';
+import { useTranslation } from '../lib/i18n';
+import { Player } from '../types';
+import { Skull, X, AlertTriangle } 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/components/GameHeader.tsx b/src/dashboard/src/components/GameHeader.tsx
index 46f2ecc..299fcfb 100644
--- a/src/dashboard/src/components/GameHeader.tsx
+++ b/src/dashboard/src/components/GameHeader.tsx
@@ -1,5 +1,6 @@
-import { Sun, Moon, Play, Pause, SkipForward } from 'lucide-react';
-import { GamePhase } from '../types';
+import { Link } from 'react-router-dom';
+import { Sun, Moon, Play, Pause, SkipForward, Mic } from 'lucide-react';
+import { GamePhase, Player, SpeechState } from '../types';
import { useTranslation } from '../lib/i18n';
interface GameHeaderProps {
@@ -7,14 +8,21 @@ interface GameHeaderProps {
dayCount: number;
timerSeconds: number;
onGlobalAction: (action: string) => void;
+ speech?: SpeechState;
+ players?: Player[];
+ readonly?: boolean;
}
-export const GameHeader: React.FC = ({ phase, dayCount, timerSeconds, onGlobalAction }) => {
+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 (
@@ -26,6 +34,28 @@ export const GameHeader: React.FC = ({ phase, dayCount, timerSe
+ {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')}`;
+ })()}
+
+ )}
+
+
+ >
+ )}
+
@@ -37,25 +67,27 @@ export const GameHeader: React.FC = ({ phase, dayCount, timerSe
- {phase === 'LOBBY' ? (
-
onGlobalAction('start_game')}
- className={`${btnStyle} ${btnPrimary}`}
- >
- {t('gameHeader.startGame')}
-
- ) : (
- <>
-
onGlobalAction('pause')} className={`${btnStyle} ${btnSecondary}`}>
-
-
+ {!readonly && (
+ phase === 'LOBBY' ? (
onGlobalAction('next_phase')}
- className={`${btnStyle} ${btnSecondary}`}
+ onClick={() => onGlobalAction('start_game')}
+ className={`${btnStyle} ${btnPrimary}`}
>
- {t('gameHeader.nextPhase')}
+ {t('gameHeader.startGame')}
- >
+ ) : (
+ <>
+
onGlobalAction('pause')} className={`${btnStyle} ${btnSecondary}`}>
+
+
+
onGlobalAction('next_phase')}
+ className={`${btnStyle} ${btnSecondary}`}
+ >
+ {t('gameHeader.nextPhase')}
+
+ >
+ )
)}
diff --git a/src/dashboard/src/components/GameLog.tsx b/src/dashboard/src/components/GameLog.tsx
index 23b2aa3..ac8c511 100644
--- a/src/dashboard/src/components/GameLog.tsx
+++ b/src/dashboard/src/components/GameLog.tsx
@@ -1,24 +1,25 @@
+import { useState } from 'react';
import { MessageSquare, AlertTriangle } from 'lucide-react';
import { LogEntry } from '../types';
import { useTranslation } from '../lib/i18n';
interface GameLogProps {
logs: LogEntry[];
- onClear: () => void;
- onManualCommand: (cmd: string) => void;
+ onGlobalAction: (action: string) => void;
+ readonly?: boolean;
+ className?: string;
}
-export const GameLog: React.FC
= ({ logs, onClear, onManualCommand }) => {
+export const GameLog: React.FC = ({ logs, onGlobalAction, readonly = false, className = "" }) => {
const { t } = useTranslation();
- const inputStyle = "bg-slate-100 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-lg px-4 py-2 text-slate-900 dark:text-slate-200 focus:outline-none focus:border-indigo-500 w-full";
+ const [resetConfirming, setResetConfirming] = useState(false);
return (
-
+
{t('gameLog.title')}
- {t('gameLog.clear')}
@@ -26,8 +27,8 @@ export const GameLog: React.FC
= ({ logs, onClear, onManualCommand
{log.timestamp}
{log.type === 'alert' &&
}
{log.message}
@@ -36,20 +37,61 @@ export const GameLog: React.FC
= ({ logs, onClear, onManualCommand
))}
- {/* Console Input */}
-
- {
- if (e.key === 'Enter') {
- onManualCommand(e.currentTarget.value);
- e.currentTarget.value = '';
- }
- }}
- />
-
+ {/* 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/components/GameSettingsPage.tsx b/src/dashboard/src/components/GameSettingsPage.tsx
new file mode 100644
index 0000000..791228a
--- /dev/null
+++ b/src/dashboard/src/components/GameSettingsPage.tsx
@@ -0,0 +1,403 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { RefreshCw, Loader2, Check, Plus, Minus, Users, AlertCircle, Dices } from 'lucide-react';
+import { ProgressOverlay } from './ProgressOverlay';
+import { useParams } from 'react-router-dom';
+import { useTranslation } from '../lib/i18n';
+import { api } from '../lib/api';
+import { useWebSocket } from '../lib/websocket';
+
+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);
+
+ // Overlay State
+ const [overlayVisible, setOverlayVisible] = useState(false);
+ const [overlayStatus, setOverlayStatus] = useState<'processing' | 'success' | 'error'>('processing');
+ const [overlayTitle, setOverlayTitle] = useState('');
+ const [overlayLogs, setOverlayLogs] = useState([]);
+ const [overlayError, setOverlayError] = useState(undefined);
+ const [overlayProgress, setOverlayProgress] = useState(0);
+
+ 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;
+
+ setOverlayTitle(t('messages.randomAssignRoles'));
+ setOverlayVisible(true);
+ setOverlayStatus('processing');
+ setOverlayLogs([t('overlayMessages.requestingAssign')]);
+ setOverlayError(undefined);
+ setOverlayProgress(0);
+
+ try {
+ await api.assignRoles(guildId);
+ setOverlayLogs(prev => [...prev]);
+ setOverlayStatus('success');
+ setOverlayLogs(prev => [...prev, t('overlayMessages.assignSuccess')]);
+
+ } catch (error: any) {
+ console.error("Assign failed", error);
+ setOverlayStatus('error');
+ const errorMessage = error.message || t('errors.unknownError');
+ setOverlayLogs(prev => [...prev, `${t('errors.error')}: ${errorMessage}`]);
+ setOverlayError(errorMessage);
+ }
+ };
+
+ const handlePlayerCountUpdate = async () => {
+ if (!guildId) return;
+
+ setOverlayTitle(t('settings.playerCount'));
+ setOverlayVisible(true);
+ setOverlayStatus('processing');
+ setOverlayLogs([t('overlayMessages.updatingPlayerCount')]);
+ setOverlayError(undefined);
+ setOverlayProgress(0);
+
+ try {
+ await api.setPlayerCount(guildId, playerCount);
+ setOverlayProgress(100);
+ setOverlayStatus('success');
+ setOverlayLogs(prev => [...prev, t('overlayMessages.playerCountUpdateSuccess')]);
+
+ // Reload settings to refresh exact state
+ loadSettings();
+ } catch (error: any) {
+ console.error("Update failed", error);
+ setOverlayStatus('error');
+ const errorMessage = error.message || t('errors.actionFailed', { action: t('buttons.update') });
+ setOverlayLogs(prev => [...prev, `${t('errors.error')}: ${errorMessage}`]);
+ setOverlayError(errorMessage);
+ }
+ };
+
+ 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')}
+
+ )}
+
+
+
+
+ setOverlayVisible(false)}
+ />
+ >
+ );
+};
diff --git a/src/dashboard/src/components/IntegrationGuide.tsx b/src/dashboard/src/components/IntegrationGuide.tsx
deleted file mode 100644
index eb5ef57..0000000
--- a/src/dashboard/src/components/IntegrationGuide.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-
-import React, { useState } from 'react';
-import { Code, Settings, Copy, Check } from 'lucide-react';
-
-interface IntegrationGuideProps {
- onClose: () => void;
-}
-
-export const IntegrationGuide: React.FC = ({ onClose }) => {
- const [copied, setCopied] = useState(false);
-
- const javaCode = `
-// =================================================================
-// JAVA DISCORD BOT INTEGRATION GUIDE (Using Javalin + JDA)
-// =================================================================
-
-// 1. Add dependencies to pom.xml / build.gradle:
-// - io.javalin:javalin:5.x
-// - com.fasterxml.jackson.core:jackson-databind
-
-public class WerewolfDashboardServer {
- private static final int PORT = 8080;
- private final WerewolfGameManager gameManager; // Your existing game logic class
-
- public WerewolfDashboardServer(WerewolfGameManager gameManager) {
- this.gameManager = gameManager;
- }
-
- public void start() {
- Javalin app = Javalin.create(config -> {
- config.plugins.enableCors(cors -> cors.add(it -> it.anyHost()));
- }).start(PORT);
-
- // API: Get Game State
- app.get("/api/state", ctx -> {
- // Verify 'Authorization' header contains valid JWT from Discord OAuth
- String token = ctx.header("Authorization");
- if (!isValidAdminToken(token)) {
- throw new ForbiddenResponse();
- }
- ctx.json(gameManager.getCurrentGameState());
- });
-
- // API: Admin Actions
- app.post("/api/action", ctx -> {
- if (!isValidAdminToken(ctx.header("Authorization"))) {
- throw new ForbiddenResponse();
- }
- // Parse action: { "playerId": "...", "action": "kill" }
- GameAction action = ctx.bodyAsClass(GameAction.class);
- gameManager.handleAdminAction(action);
- ctx.json(Map.of("status", "success"));
- });
- }
-}
- `.trim();
-
- const handleCopy = () => {
- navigator.clipboard.writeText(javaCode);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- };
-
- 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";
-
- return (
-
-
-
-
-
-
Backend Integration Instructions
-
-
-
-
-
-
-
-
-
Architecture Overview
-
- This dashboard acts as a frontend client. To make it functional, you need to expose a REST API from your Java Discord Bot.
- The dashboard will authenticate users via Discord OAuth2, then send their access token to your bot to verify Admin permissions.
-
-
-
-
-
- Java Server Implementation
-
- {copied ? : }
- {copied ? 'Copied' : 'Copy Code'}
-
-
-
-
-
-
-
-
API Specification
-
- GET /api/state - JSON object matching GameState interface
- POST /api/action - Command execution
- POST /api/auth - OAuth Code Exchange
-
-
-
-
OAuth Config
-
- Register an application in the Discord Developer Portal. Set the Redirect URI to your dashboard domain.
- Use guilds and identify scopes to verify server membership and roles.
-
-
-
-
-
-
-
- Close Guide
-
-
-
-
- );
-};
diff --git a/src/dashboard/src/components/PlayerCard.tsx b/src/dashboard/src/components/PlayerCard.tsx
index e9808de..3f75580 100644
--- a/src/dashboard/src/components/PlayerCard.tsx
+++ b/src/dashboard/src/components/PlayerCard.tsx
@@ -1,31 +1,94 @@
-import { BadgeAlert, HeartPulse, Shield, Skull, MicOff, Settings } from 'lucide-react';
+import React, { useState, useEffect } from 'react';
+import { HeartPulse, Shield, Skull, MicOff, Settings, Lock, Unlock, ArrowLeftRight } 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 }) => {
+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 && (
@@ -33,35 +96,94 @@ export const PlayerCard: React.FC
= ({ player, onAction }) => {
)}
+
+ {/* Unlock Icon - Persistent if unlocked and has multiple roles */}
+ {player.roles.length > 1 && !player.rolePositionLocked && (
+
+
+
+ )}
+
+ {/* Lock Animation Icon - Transient */}
+ {showLock && (
+
+
+
+ )}
-
{player.name}
-
-
- {t(`roles.${player.role}`)}
-
+
+
{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) */}
-
+ {/* 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 py-1.5 rounded border border-red-300 dark:border-red-900/50 flex items-center justify-center gap-1"
+ 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')}
+
+ {t('players.kill')}
) : (
= ({ player, onAction }) => {
{t('players.edit')}
-
+ )}
);
};
diff --git a/src/dashboard/src/components/PlayerEditModal.tsx b/src/dashboard/src/components/PlayerEditModal.tsx
new file mode 100644
index 0000000..1245c88
--- /dev/null
+++ b/src/dashboard/src/components/PlayerEditModal.tsx
@@ -0,0 +1,206 @@
+import React, { useState } from 'react';
+import { useTranslation } from '../lib/i18n';
+import { Player } from '../types';
+import { Shield, X, ChevronRight, Users } 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/components/PlayerSelectModal.tsx b/src/dashboard/src/components/PlayerSelectModal.tsx
new file mode 100644
index 0000000..3928263
--- /dev/null
+++ b/src/dashboard/src/components/PlayerSelectModal.tsx
@@ -0,0 +1,87 @@
+import React, { useState } from 'react';
+import { X, Search, Check } 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/components/ProgressOverlay.tsx b/src/dashboard/src/components/ProgressOverlay.tsx
new file mode 100644
index 0000000..f07cf59
--- /dev/null
+++ b/src/dashboard/src/components/ProgressOverlay.tsx
@@ -0,0 +1,142 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
+import { useTranslation } from '../lib/i18n';
+
+interface ProgressOverlayProps {
+ isVisible: boolean;
+ title: string;
+ logs: string[];
+ onComplete?: () => void;
+ autoCloseDelay?: number; // ms to wait before closing on success
+ status: 'processing' | 'success' | 'error';
+ error?: string;
+ progress?: number;
+}
+
+export const ProgressOverlay: React.FC = ({
+ isVisible,
+ title,
+ logs,
+ onComplete,
+ autoCloseDelay = 1500,
+ status,
+ error,
+ progress
+}) => {
+ const { t } = useTranslation();
+ const logEndRef = useRef(null);
+ const [isAnimating, setIsAnimating] = useState(false);
+ const [shouldRender, setShouldRender] = useState(false);
+
+ // Auto-scroll to bottom of logs
+ useEffect(() => {
+ logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [logs]);
+
+ // Handle animation on mount/unmount
+ useEffect(() => {
+ if (isVisible) {
+ setShouldRender(true);
+ // Small delay to trigger animation
+ requestAnimationFrame(() => {
+ setIsAnimating(true);
+ });
+ } else {
+ setIsAnimating(false);
+ // Wait for animation to complete before unmounting
+ const timer = setTimeout(() => {
+ setShouldRender(false);
+ }, 300); // Match animation duration
+ return () => clearTimeout(timer);
+ }
+ }, [isVisible]);
+
+ // Handle auto-close
+ useEffect(() => {
+ if (status === 'success' && onComplete) {
+ const timer = setTimeout(() => {
+ onComplete();
+ }, autoCloseDelay);
+ return () => clearTimeout(timer);
+ }
+ }, [status, onComplete, autoCloseDelay]);
+
+ if (!shouldRender) return null;
+
+ return (
+
+
+
+ {/* 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')}
}
+
+
+
+ {/* Log Terminal */}
+
+
+ {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/ServerSelector.tsx b/src/dashboard/src/components/ServerSelector.tsx
new file mode 100644
index 0000000..366f870
--- /dev/null
+++ b/src/dashboard/src/components/ServerSelector.tsx
@@ -0,0 +1,149 @@
+import { useState, useEffect } from 'react';
+import { Server, Users, Loader2 } from 'lucide-react';
+import { useTranslation } from '../lib/i18n';
+import { api } from '../lib/api';
+
+interface Session {
+ guildId: string;
+ guildName: string;
+ guildIcon?: string;
+ players: any[];
+}
+
+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.players?.length || 0} {t('serverSelector.players')}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {t('serverSelector.backToLogin')}
+
+
+
+
+ );
+};
diff --git a/src/dashboard/src/components/SettingsModal.tsx b/src/dashboard/src/components/SettingsModal.tsx
new file mode 100644
index 0000000..6db875b
--- /dev/null
+++ b/src/dashboard/src/components/SettingsModal.tsx
@@ -0,0 +1,117 @@
+import { useState } from 'react';
+import { X, Check, AlertCircle, Wifi } 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/components/Sidebar.tsx b/src/dashboard/src/components/Sidebar.tsx
index 627d90a..84610af 100644
--- a/src/dashboard/src/components/Sidebar.tsx
+++ b/src/dashboard/src/components/Sidebar.tsx
@@ -1,14 +1,40 @@
-import { Moon, Activity, Settings, Code, LogOut } from 'lucide-react';
+import { Moon, Activity, Settings, LogOut, LayoutGrid } from 'lucide-react';
import { useTranslation } from '../lib/i18n';
import { ThemeToggle } from './ThemeToggle';
+import { useAuth } from '../contexts/AuthContext';
+import { useLocation } from 'react-router-dom';
interface SidebarProps {
onLogout: () => void;
- onShowGuide: () => void;
+ onSettingsClick: () => void;
+ onDashboardClick: () => void;
+ onSpectatorClick: () => void;
+ onSpeechClick: () => void;
+ onSwitchServer: () => void;
+ onToggleSpectatorMode: () => void;
+ isSpectatorMode: boolean;
+ isConnected: boolean;
}
-export const Sidebar: React.FC = ({ onLogout, onShowGuide }) => {
+export const Sidebar: React.FC = ({
+ onLogout,
+ onSettingsClick,
+ onDashboardClick,
+ onSpectatorClick,
+ onSpeechClick,
+ onSwitchServer,
+ onToggleSpectatorMode,
+ isSpectatorMode,
+ isConnected
+}) => {
const { t } = useTranslation();
+ const { user } = useAuth();
+ const location = useLocation();
+
+ const isDashboardActive = location.pathname.endsWith(user?.guildId ? `/server/${user.guildId}` : '/') && !location.pathname.includes('/settings') && !location.pathname.includes('/spectator') && !location.pathname.includes('/speech');
+ const isSettingsActive = location.pathname.includes('/settings');
+ const isSpectatorActive = location.pathname.includes('/spectator');
+ const isSpeechActive = location.pathname.includes('/speech');
return (
@@ -17,39 +43,120 @@ export const Sidebar: React.FC = ({ onLogout, onShowGuide }) => {
- 狼人助手
+ {t('app.title').split('助手')[0]}助手
-
-
- {t('sidebar.dashboard')}
-
-
-
- {t('sidebar.gameSettings')}
-
-
-
- {t('sidebar.integrationGuide')}
-
+ {user?.role === 'JUDGE' && !isSpectatorMode && (
+ <>
+
+
+ {t('sidebar.dashboard')}
+
+
+
+ {t('sidebar.gameSettings')}
+
+ >
+ )}
+
+ {(user?.role === 'JUDGE' || user?.role === 'SPECTATOR') && (
+ <>
+
+
+ {t('sidebar.spectator')}
+
+
+
+ {t('sidebar.speechManager')}
+
+ >
+ )}
+
-
+
+ {/* User Profile */}
+ {user && (
+
+
+
+
+ {user.username}
+
+ {user.role === 'JUDGE' ? (
+
+ {isSpectatorMode ? t('userRoles.SPECTATOR') : t('userRoles.JUDGE')}
+
+ ) : (
+
+ {t(`userRoles.${user.role}`) || user.role}
+
+ )}
+
+
+ )}
+
+ {/* Connection Status */}
-
-
{t('sidebar.botConnected')}
+
+
+ {isConnected ? t('sidebar.botConnected') : t('sidebar.botDisconnected')}
+
+ {/* Action Buttons */}
-
+
+
+
+
- {t('sidebar.signOut')}
+ {t('sidebar.signOut')}
diff --git a/src/dashboard/src/components/SpectatorView.tsx b/src/dashboard/src/components/SpectatorView.tsx
new file mode 100644
index 0000000..7f91fab
--- /dev/null
+++ b/src/dashboard/src/components/SpectatorView.tsx
@@ -0,0 +1,220 @@
+import React, { useMemo } from 'react';
+import { useTranslation } from '../lib/i18n';
+import { Player } from '../types';
+import { Skull, Shield, Zap, HeartPulse, Users } from 'lucide-react';
+import { PlayerCard } from './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/components/SpeechManager.tsx b/src/dashboard/src/components/SpeechManager.tsx
new file mode 100644
index 0000000..2296b2e
--- /dev/null
+++ b/src/dashboard/src/components/SpeechManager.tsx
@@ -0,0 +1,371 @@
+import { useState, useEffect } from 'react';
+import { Play, SkipForward, Square, Mic, Clock, Shield, ArrowUp, ArrowDown, UserPlus, UserMinus } from 'lucide-react';
+import { Player, SpeechState, PoliceState } from '../types';
+import { api } from '../lib/api';
+import { useTranslation } from '../lib/i18n';
+
+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
+ const isPoliceActive = police && (police.allowEnroll || police.allowUnEnroll);
+
+ 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')}
+
+
+ )}
+
+ );
+ }
+
+ // 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) {
+ return (
+
+
+
+
{t('speechManager.policeEnrollment')}
+
+
+
+
+
+ {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(cid => {
+ const p = players.find(x => x.id === cid);
+ return (
+
+
+
{p?.name || `Player ${cid}`}
+
+ );
+ })}
+
+ ) : (
+
{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')}
+
+
+ )}
+
+ ) : ( // ... rest of the speech UI
+ <>
+ {/* 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')}
+ )}
+ >
+ )}
+
+
+ );
+};
+
+interface SpeakerCardProps {
+ player: Player;
+ timeLeft: number;
+ t: any;
+ readonly: boolean;
+ onSkip?: () => void;
+ onInterrupt?: () => void;
+}
+
+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/components/ThemeToggle.tsx b/src/dashboard/src/components/ThemeToggle.tsx
index 15f0afe..3bd7053 100644
--- a/src/dashboard/src/components/ThemeToggle.tsx
+++ b/src/dashboard/src/components/ThemeToggle.tsx
@@ -1,14 +1,16 @@
import { Sun, Moon } 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/components/TimerControlModal.tsx b/src/dashboard/src/components/TimerControlModal.tsx
new file mode 100644
index 0000000..be1b131
--- /dev/null
+++ b/src/dashboard/src/components/TimerControlModal.tsx
@@ -0,0 +1,90 @@
+import React, { useState } from 'react';
+import { X, Clock, Play } 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, 180, 300];
+
+ 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/contexts/AuthContext.tsx b/src/dashboard/src/contexts/AuthContext.tsx
new file mode 100644
index 0000000..eb73d26
--- /dev/null
+++ b/src/dashboard/src/contexts/AuthContext.tsx
@@ -0,0 +1,95 @@
+import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
+
+interface User {
+ userId: string;
+ username: string;
+ avatar: string;
+ guildId: number;
+ role: 'JUDGE' | 'SPECTATOR' | 'BLOCKED' | 'PENDING';
+}
+
+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/index.css b/src/dashboard/src/index.css
index 005070a..60a250b 100644
--- a/src/dashboard/src/index.css
+++ b/src/dashboard/src/index.css
@@ -6,7 +6,7 @@
* {
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;
}
@@ -14,9 +14,163 @@
/* Custom Scrollbar */
.scrollbar-hide::-webkit-scrollbar {
- display: none;
+ display: none;
}
+
.scrollbar-hide {
- -ms-overflow-style: none;
- scrollbar-width: none;
+ -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/api.ts b/src/dashboard/src/lib/api.ts
new file mode 100644
index 0000000..ca348a4
--- /dev/null
+++ b/src/dashboard/src/lib/api.ts
@@ -0,0 +1,218 @@
+const DEFAULT_BACKEND_URL = ''; // Empty string means use current origin (relative URLs)
+
+export class ApiClient {
+ private baseUrl: string;
+
+ constructor() {
+ this.baseUrl = this.getBackendUrl();
+ }
+
+ private getBackendUrl(): string {
+ return localStorage.getItem('backendUrl') || DEFAULT_BACKEND_URL;
+ }
+
+ 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, {
+ ...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/websocket.ts b/src/dashboard/src/lib/websocket.ts
new file mode 100644
index 0000000..dbd63de
--- /dev/null
+++ b/src/dashboard/src/lib/websocket.ts
@@ -0,0 +1,180 @@
+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 onConnectHandlers: Set<() => void> = new Set();
+ private onDisconnectHandlers: Set<() => void> = new Set();
+
+ constructor() {
+ this.url = this.getWebSocketUrl();
+ }
+
+ private getWebSocketUrl(): string {
+ const backendUrl = api.getConfiguredUrl();
+ if (!backendUrl) {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ // In development, handle Vite's proxy or different ports if needed
+ const host = window.location.host;
+ return `${protocol}//${host}/ws`;
+ }
+ return backendUrl.replace(/^http/, 'ws') + '/ws';
+ }
+
+ public get isConnected(): boolean {
+ return this.ws?.readyState === WebSocket.OPEN;
+ }
+
+ connect() {
+ 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}), reconnecting...`);
+ this.ws = null;
+ this.onDisconnectHandlers.forEach(h => h());
+ 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
+ this.ws.close();
+ this.ws = null;
+ this.onDisconnectHandlers.forEach(h => h());
+ }
+ }
+
+ addConnectionHandlers(onConnect: () => void, onDisconnect: () => 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) {
+ 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),
+ () => setIsConnected(false)
+ );
+
+ // Subscribe to messages
+ const unsubscribeMsg = wsClient.onMessage((data) => {
+ onMessageRef.current(data);
+ });
+
+ // Ensure we are connected
+ wsClient.connect();
+
+ // Heartbeat interval
+ const interval = setInterval(() => {
+ if (wsClient.isConnected) {
+ wsClient.send({ type: 'PING' });
+ } else {
+ wsClient.connect(); // 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
index 0cbf941..7d38ae1 100644
--- a/src/dashboard/src/locales/zh-TW.json
+++ b/src/dashboard/src/locales/zh-TW.json
@@ -9,18 +9,47 @@
"loginButton": "使用 Discord 登入",
"restriction": "僅限狼人伺服器管理員"
},
+ "auth": {
+ "loggingIn": "登入中..."
+ },
"sidebar": {
"dashboard": "儀表板",
+ "switchServer": "切換伺服器",
"gameSettings": "遊戲設定",
"integrationGuide": "整合指南",
"botConnected": "機器人已連接",
- "signOut": "登出"
+ "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": "下一階段"
+ "nextPhase": "下一階段",
+ "lastWords": "遺言"
},
"phases": {
"LOBBY": "大廳",
@@ -34,16 +63,50 @@
"alive": "存活",
"dead": "死亡",
"kill": "殺死",
+ "confirmKill": "確認殺死?",
"revive": "復活",
- "edit": "編輯"
+ "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": "守衛"
+ "GUARD": "守衛",
+ "unknown": "未知身分"
},
"status": {
"sheriff": "警長",
@@ -54,14 +117,9 @@
},
"gameLog": {
"title": "遊戲日誌",
- "clear": "清除",
"placeholder": "輸入手動命令...",
- "systemInit": "系統已初始化。等待連接中...",
- "adminLogin": "管理員透過 Discord OAuth 模擬登入。",
"gameStarted": "遊戲已開始!",
"gamePaused": "遊戲計時器已被管理員暫停。",
- "gameReset": "遊戲已重置。",
- "broadcastRoles": "正在透過私訊向所有玩家廣播角色...",
"randomizeRoles": "正在隨機分配角色...",
"adminCommand": "管理員執行命令:/{action} 對 {player}",
"adminGlobalCommand": "管理員執行全域命令:/{action}",
@@ -69,33 +127,188 @@
},
"globalCommands": {
"title": "全域管理員命令",
- "broadcastRole": "廣播角色(私訊)",
"randomAssign": "隨機分配角色",
- "forceReset": "強制重置"
+ "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": "返回登入"
+ },
+ "settings": {
+ "general": "一般設定",
+ "muteAfterSpeech": "發言後靜音",
+ "muteAfterSpeechDesc": "玩家發言結束後自動將其靜音",
+ "doubleIdentities": "雙重身分",
+ "doubleIdentitiesDesc": "每位玩家獲得兩個角色",
+ "playerCount": "玩家人數設定",
+ "totalPlayers": "總玩家數",
+ "playerCountDesc": "調整此數值將會自動建立或刪除遊戲頻道。"
+ },
+ "buttons": {
+ "update": "更新"
},
- "integrationGuide": {
- "title": "整合指南",
+ "speechManager": {
+ "startAuto": "開始自動發言",
+ "startPoliceEnroll": "啟動警長參選",
+ "skip": "強制換人",
+ "interrupt": "終止流程",
+ "noActiveSpeech": "目前沒有正在進行的自動發言流程。",
+ "noActiveSpeechJudge": "目前沒有正在進行的自動發言流程。身為法官,您可以隨時開始新的流程。",
+ "activeSpeech": "發言進行中",
+ "autoProcess": "自動流程",
+ "speaking": "正在發言",
+ "waiting": "等待發言",
+ "noMoreSpeakers": "沒有更多發言者",
+ "interruptVote": "下台投票",
+ "preparing": "準備中...",
+ "policeEnrollment": "警長參選",
+ "allowEnroll": "允許參選",
+ "allowUnEnroll": "允許退選",
+ "candidates": "參選名單",
+ "noCandidates": "目前無參選者...",
+ "waitingForPolice": "等待警長選擇發言順序...",
+ "waitingForPoliceSub": "警長正在選擇發言順序 (上警/下警)",
+ "forceUp": "強制往上 (死者/小號)",
+ "forceDown": "強制往下 (死者/小號)",
+ "judgeOverride": "法官強制操作"
+ },
+ "progressOverlay": {
+ "operationFailed": "操作失敗",
+ "processing": "正在處理請求...",
+ "complete": "完成!",
+ "unknownError": "發生未知錯誤",
"close": "關閉",
- "overview": "概述",
- "overviewText": "此儀表板透過 HTTP REST API 與您的 Java Discord 機器人通訊。以下是如何整合此前端與您的後端的逐步指南。",
- "step1": "步驟 1:設定 CORS",
- "step1Text": "確保您的 Java 伺服器允許來自此儀表板來源的請求。",
- "step2": "步驟 2:實作 API 端點",
- "step2Text": "建立 REST 控制器來處理遊戲狀態更新。",
- "step3": "步驟 3:WebSocket(可選)",
- "step3Text": "對於即時更新,使用 WebSocket 將遊戲事件推送到儀表板。",
- "step4": "步驟 4:Discord OAuth",
- "step4Text": "使用 Discord OAuth2 進行身份驗證,以限制管理員存取。",
- "apiReference": "API 參考",
- "getGameState": "取得遊戲狀態",
- "updatePlayer": "更新玩家",
- "globalAction": "全域動作",
- "notes": "注意事項",
- "notesText": "將 {baseUrl} 替換為您的 Java 伺服器 URL(例如 http://localhost:8080),並確保在正式環境中使用 HTTPS。"
- },
- "theme": {
- "toggle": "切換主題",
- "light": "淺色模式",
- "dark": "深色模式"
+ "ok": "確定",
+ "resetTitle": "重置遊戲"
+ },
+ "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": "玩家 (受限)"
}
}
\ No newline at end of file
diff --git a/src/dashboard/src/main.tsx b/src/dashboard/src/main.tsx
index 2b67615..f99620b 100644
--- a/src/dashboard/src/main.tsx
+++ b/src/dashboard/src/main.tsx
@@ -1,11 +1,17 @@
import { createRoot } from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from './lib/ThemeProvider';
+import { AuthProvider } from './contexts/AuthContext';
import App from './App';
import './index.css';
const root = createRoot(document.getElementById('root')!);
root.render(
-
+
+
+
+
+
);
diff --git a/src/dashboard/src/mockData.ts b/src/dashboard/src/mockData.ts
index db18200..dcf2c7a 100644
--- a/src/dashboard/src/mockData.ts
+++ b/src/dashboard/src/mockData.ts
@@ -17,7 +17,8 @@ export const INITIAL_PLAYERS: Player[] = Array.from({ length: 8 }).map((_, i) =>
discordId: `u-${i}`,
name: `Player ${i + 1}`,
avatar: MOCK_AVATARS[i],
- role: i === 0 ? 'WEREWOLF' : i === 1 ? 'SEER' : i === 2 ? 'WITCH' : 'VILLAGER',
+ roles: i === 0 ? ['WEREWOLF'] : i === 1 ? ['SEER'] : i === 2 ? ['WITCH'] : ['VILLAGER'],
+ deadRoles: [],
isAlive: true,
isSheriff: false,
isJinBaoBao: i === 3,
diff --git a/src/dashboard/src/types.ts b/src/dashboard/src/types.ts
index c05fb7b..ccc6281 100644
--- a/src/dashboard/src/types.ts
+++ b/src/dashboard/src/types.ts
@@ -1,34 +1,84 @@
+// 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 = 'WEREWOLF' | 'VILLAGER' | 'SEER' | 'WITCH' | 'HUNTER' | 'GUARD' | 'IDIOT' | 'WOLF_KING';
+// 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;
+}
+
+export interface PoliceState {
+ allowEnroll: boolean;
+ allowUnEnroll: boolean;
+ candidates: string[]; // List of Player IDs (internal IDs)
+}
+
+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;
- discordId: 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;
- role: Role;
isAlive: boolean;
isSheriff: boolean;
isJinBaoBao: boolean;
isProtected: boolean;
isPoisoned: boolean;
isSilenced: boolean;
- hasVoted: boolean;
+ isDuplicated?: boolean;
+ isJudge?: boolean;
+ rolePositionLocked?: boolean;
}
export interface LogEntry {
id: string;
timestamp: string;
message: string;
- type: 'info' | 'action' | 'alert' | 'chat';
-}
-
-export interface GameState {
- phase: GamePhase;
- dayCount: number;
- timerSeconds: number;
- players: Player[];
- logs: LogEntry[];
- winner?: 'WEREWOLVES' | 'VILLAGERS' | null;
+ type: 'info' | 'action' | 'alert';
}
diff --git a/src/dashboard/vite.config.ts b/src/dashboard/vite.config.ts
index 2dea53a..8f1c758 100644
--- a/src/dashboard/vite.config.ts
+++ b/src/dashboard/vite.config.ts
@@ -4,4 +4,17 @@ import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
+ 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
index ecfb6bc..3aef4ea 100644
--- a/src/dashboard/yarn.lock
+++ b/src/dashboard/yarn.lock
@@ -625,6 +625,11 @@ convert-source-map@^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"
@@ -994,6 +999,21 @@ react-refresh@^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"
@@ -1082,6 +1102,11 @@ semver@^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"
diff --git a/src/main/java/dev/robothanzo/werewolf/WerewolfHelper.java b/src/main/java/dev/robothanzo/werewolf/WerewolfHelper.java
index 7ce8518..09c9108 100644
--- a/src/main/java/dev/robothanzo/werewolf/WerewolfHelper.java
+++ b/src/main/java/dev/robothanzo/werewolf/WerewolfHelper.java
@@ -1,10 +1,12 @@
package dev.robothanzo.werewolf;
+import club.minnced.discord.jdave.interop.JDaveSessionFactory;
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.server.WebServer;
import dev.robothanzo.werewolf.listeners.ButtonListener;
import dev.robothanzo.werewolf.listeners.GuildJoinListener;
import dev.robothanzo.werewolf.listeners.MemberJoinListener;
@@ -13,6 +15,7 @@
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.requests.GatewayIntent;
import net.dv8tion.jda.api.utils.ChunkingFilter;
@@ -39,6 +42,7 @@ public class WerewolfHelper {
"守墓人", "魔術師", "黑市商人", "邱比特", "盜賊", "石像鬼", "狼兄", "狼弟", "複製人", "血月使者", "惡靈騎士", "通靈師", "機械狼", "獵魔人"
);
public static JDA jda;
+ public static WebServer webServer;
public static AudioPlayerManager playerManager = new DefaultAudioPlayerManager();
@SneakyThrows
@@ -53,10 +57,19 @@ public static void main(String[] args) {
.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"));
+
+ // Start web server in separate thread
+ webServer = new WebServer(8080);
+ webServer.setJDA(jda);
+ Thread serverThread = new Thread(webServer);
+ serverThread.setDaemon(true);
+ serverThread.start();
+ log.info("Dashboard web server started on port 8080");
// new JDAInteractions("dev.robothanzo.werewolf.commands")
// .registerInteractions(jda.getGuildById(dotenv.get("GUILD"))).queue();
}
diff --git a/src/main/java/dev/robothanzo/werewolf/commands/Player.java b/src/main/java/dev/robothanzo/werewolf/commands/Player.java
index 091b6e9..f4006f0 100644
--- a/src/main/java/dev/robothanzo/werewolf/commands/Player.java
+++ b/src/main/java/dev/robothanzo/werewolf/commands/Player.java
@@ -42,7 +42,7 @@ public static void transferPolice(Session session, Guild guild, Session.Player p
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());
@@ -69,11 +69,51 @@ public static boolean playerDied(Session session, Member user, boolean lastWords
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);
+ }
+
+ // 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 +126,58 @@ 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();
+ .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);
}
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()));
+ WerewolfHelper.webServer.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);
+ WerewolfHelper.webServer.broadcastSessionUpdate(newSession);
}
+ });
+
+ if (lastWords) {
+ Speech.lastWordsSpeech(guild, Objects.requireNonNull(guild.getTextChannelById(session.getCourtTextChannelId())), player.getValue(), die);
+ } else {
+ die.run();
}
return true;
}
@@ -126,6 +187,72 @@ 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(WerewolfHelper.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
+ WerewolfHelper.webServer.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();
@@ -159,17 +286,32 @@ public static void confirmNewPolice(ButtonInteractionEvent event) {
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();
} else {
@@ -210,6 +352,7 @@ public void changeRoleOrder(ButtonInteractionEvent event) {
event.reply(":white_check_mark: 你目前的順序: " + String.join("、", player.getRoles())).queue();
Session.fetchCollection().updateOne(eq("guildId", Objects.requireNonNull(event.getGuild()).getIdLong()),
set("players", session.getPlayers()));
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
return;
}
}
@@ -261,102 +404,19 @@ public void assign(SlashCommandInteractionEvent event) {
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();
- return;
- }
- if (pending.size() != (session.getRoles().size() / (session.isDoubleIdentities() ? 2 : 1))) {
- event.getHook().editOriginal(
- ":x: 玩家身分數量不符合身分數量,請確認是否正確啟用/停用雙身分模式(使用`/server set double_identities`),並檢查是否正確設定身分(使用`/server roles list`檢查)").queue();
- return;
- }
- 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()));
+
+ try {
+ dev.robothanzo.werewolf.server.SessionAPI.assignRoles(event.getGuild().getIdLong(), event.getJDA(),
+ msg -> log.info("[Assign] " + msg),
+ p -> {}
+ );
+ event.getHook().editOriginal(":white_check_mark: 身分分配完成!").queue();
+ } catch (Exception e) {
+ event.getHook().editOriginal(":x: " + e.getMessage()).queue();
}
- 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();
@@ -369,7 +429,7 @@ public void roles(SlashCommandInteractionEvent event) {
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);
}
@@ -389,17 +449,13 @@ public void force_police(SlashCommandInteractionEvent
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 (member != null) player.updateNickname(member);
}
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 (member != null) player.updateNickname(member);
}
}
event.getHook().editOriginal(":white_check_mark:").queue();
diff --git a/src/main/java/dev/robothanzo/werewolf/commands/Poll.java b/src/main/java/dev/robothanzo/werewolf/commands/Poll.java
index d1e512e..ae31f7a 100644
--- a/src/main/java/dev/robothanzo/werewolf/commands/Poll.java
+++ b/src/main/java/dev/robothanzo/werewolf/commands/Poll.java
@@ -5,6 +5,7 @@
import dev.robothanzo.werewolf.WerewolfHelper;
import dev.robothanzo.werewolf.audio.Audio;
import dev.robothanzo.werewolf.database.documents.Session;
+import dev.robothanzo.werewolf.server.WebServer;
import dev.robothanzo.werewolf.utils.CmdUtils;
import dev.robothanzo.werewolf.utils.MsgUtils;
import lombok.Builder;
@@ -56,7 +57,7 @@ public static void startExpelPoll(Session session, GuildMessageChannel channel,
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() + ")"));
+ player.getPlayer().getNickname() + " (" + user.getUser().getName() + ")"));
}
Message message = channel.sendMessageEmbeds(embedBuilder.build())
.setComponents(MsgUtils.spreadButtonsAcrossActionRows(buttons).toArray(new ActionRow[0])).complete();
@@ -102,11 +103,11 @@ public static void sendVoteResult(Session session, GuildMessageChannel channel,
User user = WerewolfHelper.jda.getUserById(candidate.getPlayer().getUserId());
assert user != null;
voted.addAll(candidate.getElectors());
- resultEmbed.addField("玩家" + candidate.getPlayer().getId() + " (" + user.getName() + ")",
+ resultEmbed.addField(candidate.player.getNickname() + " (" + user.getName() + ")",
String.join("、", candidate.getElectorsAsMention()), false);
}
List discarded = new LinkedList<>();
- for (Session.Player player : session.getPlayers().values()) {
+ for (Session.Player player : session.fetchAlivePlayers().values()) {
if ((candidates.get(channel.getGuild().getIdLong()).get(player.getId()) == null || !police) &&
!voted.contains(player.getUserId())) {
discarded.add("<@!" + player.getUserId() + ">");
@@ -124,7 +125,7 @@ public void expel(SlashCommandInteractionEvent event) {
if (session == null) return;
Map candidates = new ConcurrentHashMap<>();
- for (Session.Player player : session.getPlayers().values()) {
+ for (Session.Player player : session.fetchAlivePlayers().values()) {
candidates.put(player.getId(), Candidate.builder().player(player).build());
}
expelCandidates.put(event.getGuild().getIdLong(), candidates);
@@ -165,7 +166,7 @@ public static void startPolicePoll(Session session, GuildMessageChannel channel,
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() + ")"));
+ player.getPlayer().getNickname() + " (" + user.getUser().getName() + ")"));
}
Message message = channel.sendMessageEmbeds(embedBuilder.build())
.setComponents(MsgUtils.spreadButtonsAcrossActionRows(buttons).toArray(new ActionRow[0])).complete();
@@ -175,6 +176,9 @@ public static void startPolicePoll(Session session, GuildMessageChannel channel,
if (winners.isEmpty()) {
message.reply("沒有人投票,警徽撕毀").queue();
candidates.remove(channel.getGuild().getIdLong());
+ allowEnroll.remove(channel.getGuild().getIdLong());
+ allowUnEnroll.remove(channel.getGuild().getIdLong());
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
return;
}
if (winners.size() == 1) {
@@ -184,11 +188,22 @@ public static void startPolicePoll(Session session, GuildMessageChannel channel,
.setDescription("獲勝玩家: <@!" + winners.getFirst().getPlayer().getUserId() + ">");
sendVoteResult(session, channel, message, resultEmbed, candidates, true);
candidates.remove(channel.getGuild().getIdLong());
+ allowEnroll.remove(channel.getGuild().getIdLong());
+ allowUnEnroll.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));
+
+ // Log police election
+ Map metadata = new HashMap<>();
+ metadata.put("playerId", winners.getFirst().getPlayer().getId());
+ metadata.put("playerName", winners.getFirst().getPlayer().getNickname());
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.POLICE_ELECTED,
+ winners.getFirst().getPlayer().getNickname() + " 當選警長", metadata);
+
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
}
if (winners.size() > 1) {
if (allowPK) {
@@ -202,6 +217,9 @@ public static void startPolicePoll(Session session, GuildMessageChannel channel,
message.reply("平票第二次,警徽撕毀").queue();
sendVoteResult(session, channel, message, resultEmbed, candidates, true);
candidates.remove(channel.getGuild().getIdLong());
+ allowEnroll.remove(channel.getGuild().getIdLong());
+ allowUnEnroll.remove(channel.getGuild().getIdLong());
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
}
}
}, 30000);
@@ -219,11 +237,29 @@ public void enrollPolice(ButtonInteractionEvent event) {
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();
+
+ // Log unenrollment
+ 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);
+
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
} 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();
+
+ // Log unenrollment
+ 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);
+
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
} else {
event.getHook().editOriginal(":x: 無法取消參選,投票已開始").queue();
}
@@ -233,63 +269,101 @@ public void enrollPolice(ButtonInteractionEvent event) {
if ((!allowEnroll.containsKey(event.getGuild().getIdLong())) || !allowEnroll.get(event.getGuild().getIdLong())) {
event.getHook().editOriginal(":x: 無法參選,時間已到").queue();
}
- for (Session.Player player : session.getPlayers().values()) {
+ for (Session.Player player : session.fetchAlivePlayers().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();
+
+ // Log enrollment
+ 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);
+
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
return;
}
}
event.getHook().editOriginal(":x: 你不是玩家").queue();
}
- @Subcommand(description = "啟動警長參選投票")
- public void enroll(SlashCommandInteractionEvent event) {
- event.deferReply().queue();
- 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()));
+ public static void startEnrollment(Session session, GuildMessageChannel channel, @Nullable SlashCommandInteractionEvent event) {
+ candidates.put(channel.getGuild().getIdLong(), new ConcurrentHashMap<>());
+ allowEnroll.put(channel.getGuild().getIdLong(), true);
+ allowUnEnroll.put(channel.getGuild().getIdLong(), true);
+
+ // Log enrollment start
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.POLICE_ENROLLMENT_STARTED,
+ "警長參選已開始", null);
+
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
+ Audio.play(Audio.Resource.POLICE_ENROLL, channel.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);
+
+ Message message;
+ if (event != null) {
+ message = event.getHook().editOriginalEmbeds(embed.build())
+ .setComponents(ActionRow.of(Button.success("enrollPolice", "參選警長")))
+ .complete();
+ } else {
+ message = channel.sendMessageEmbeds(embed.build())
+ .setComponents(ActionRow.of(Button.success("enrollPolice", "參選警長")))
+ .complete();
+ }
+
+ CmdUtils.schedule(() -> Audio.play(Audio.Resource.ENROLL_10S_REMAINING, channel.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());
+ allowEnroll.put(channel.getGuild().getIdLong(), false);
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
+ if (candidates.get(channel.getGuild().getIdLong()).isEmpty()) {
+ candidates.remove(channel.getGuild().getIdLong());
+ allowEnroll.remove(channel.getGuild().getIdLong());
+ allowUnEnroll.remove(channel.getGuild().getIdLong());
message.reply("無人參選,警徽撕毀").queue();
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
return;
}
List candidateMentions = new LinkedList<>();
- for (Candidate candidate : candidates.get(event.getGuild().getIdLong()).values().stream().sorted(Candidate.getComparator()).toList()) {
+ for (Candidate candidate : candidates.get(channel.getGuild().getIdLong()).values().stream().sorted(Candidate.getComparator()).toList()) {
candidateMentions.add("<@!" + candidate.getPlayer().getUserId() + ">");
}
- if (candidates.get(event.getGuild().getIdLong()).size() == 1) {
+ if (candidates.get(channel.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()));
+ Member member = channel.getGuild().getMemberById(Objects.requireNonNull(candidates.get(channel.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());
+ Session.fetchCollection().updateOne(eq("guildId", channel.getGuild().getIdLong()),
+ set("players." + candidates.get(channel.getGuild().getIdLong()).get(0).getPlayer().getId() + ".police", true));
+ candidates.remove(channel.getGuild().getIdLong());
+ allowEnroll.remove(channel.getGuild().getIdLong());
+ allowUnEnroll.remove(channel.getGuild().getIdLong());
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
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(),
+ Speech.pollSpeech(channel.getGuild(), message, candidates.get(channel.getGuild().getIdLong()).values().stream().map(Candidate::getPlayer).toList(),
() -> {
message.getChannel().sendMessage("政見發表結束,參選人有20秒進行退選,20秒後將自動開始投票").queue();
- CmdUtils.schedule(() -> startPolicePoll(session, event.getGuildChannel(), true), 20000);
+ CmdUtils.schedule(() -> startPolicePoll(session, channel, true), 20000);
});
}, 30000);
- event.getHook().editOriginal(":white_check_mark:").queue();
+
+ if (event != null) {
+ event.getHook().editOriginal(":white_check_mark:").queue();
+ }
+ }
+
+ @Subcommand(description = "啟動警長參選投票")
+ public void enroll(SlashCommandInteractionEvent event) {
+ event.deferReply().queue();
+ if (!CmdUtils.isAdmin(event)) return;
+ Session session = CmdUtils.getSession(Objects.requireNonNull(event.getGuild()));
+ if (session == null) return;
+ startEnrollment(session, event.getGuildChannel(), event);
}
@Subcommand(description = "啟動警長投票 (會自動開始,請只在出問題時使用)")
diff --git a/src/main/java/dev/robothanzo/werewolf/commands/Server.java b/src/main/java/dev/robothanzo/werewolf/commands/Server.java
index 4e7c178..c0dab62 100644
--- a/src/main/java/dev/robothanzo/werewolf/commands/Server.java
+++ b/src/main/java/dev/robothanzo/werewolf/commands/Server.java
@@ -128,6 +128,22 @@ public void session(SlashCommandInteractionEvent event, @Option(value = "guild_i
}
}
+ @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) {
}
@@ -260,8 +276,9 @@ public void players(SlashCommandInteractionEvent event, @Option(value = "value")
}
}
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)
+ 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())
diff --git a/src/main/java/dev/robothanzo/werewolf/commands/Speech.java b/src/main/java/dev/robothanzo/werewolf/commands/Speech.java
index a77f556..0dff2d1 100644
--- a/src/main/java/dev/robothanzo/werewolf/commands/Speech.java
+++ b/src/main/java/dev/robothanzo/werewolf/commands/Speech.java
@@ -11,6 +11,7 @@
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.Permission;
import net.dv8tion.jda.api.entities.Guild;
@@ -32,6 +33,7 @@
import java.time.Duration;
import java.util.*;
+@Slf4j
@Command
public class Speech {
public static Map speechSessions = new HashMap<>();
@@ -122,17 +124,23 @@ public void selectOrder(StringSelectInteractionEvent event) {
return;
}
Session.Player target = null;
- for (Session.Player player : session.getPlayers().values()) {
+ for (Session.Player player : session.fetchAlivePlayers().values()) { // selectOrder
assert player.getUserId() != null;
- if (player.getUserId() == event.getUser().getIdLong() && !player.isPolice()) {
- event.getHook().editOriginal(":x: 你不是警長").queue();
- return;
- }
- if (player.isPolice()) {
- target = player;
+ if (player.getUserId() == event.getUser().getIdLong()) {
+ if (player.isPolice()) {
+ target = player;
+ break;
+ } else {
+ event.getHook().editOriginal(":x: 你不是警長").queue();
+ return;
+ }
}
}
- changeOrder(event.getGuild(), order, session.getPlayers().values(), target);
+ if (target == null) {
+ event.getHook().editOriginal(":x: 你不是警長").queue();
+ return;
+ }
+ changeOrder(event.getGuild(), order, session.fetchAlivePlayers().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();
@@ -149,7 +157,7 @@ public void confirmOrder(ButtonInteractionEvent event) {
}
SpeechSession speechSession = speechSessions.get(Objects.requireNonNull(event.getGuild()).getIdLong());
boolean check = false;
- for (Session.Player player : session.getPlayers().values()) {
+ for (Session.Player player : session.fetchAlivePlayers().values()) {
assert player.getUserId() != null;
if (player.getUserId() == event.getUser().getIdLong()) {
if (player.isPolice()) {
@@ -204,12 +212,13 @@ public void interruptSpeech(ButtonInteractionEvent event) {
} else {
if (session.getInterruptVotes().contains(event.getUser().getIdLong())) {
event.getHook().editOriginal(":white_check_mark: 成功取消下台投票,距離該玩家下台還缺" +
- (gameSession.getPlayers().size() / 2 - session.getInterruptVotes().size()) + "票").queue();
+ (gameSession.fetchAlivePlayers().size() / 2 - session.getInterruptVotes().size()) + "票").queue();
} else {
event.getHook().editOriginal(":white_check_mark: 下台投票成功,距離該玩家下台還缺" +
- (gameSession.getPlayers().size() / 2 - session.getInterruptVotes().size()) + "票").queue();
+ (gameSession.fetchAlivePlayers().size() / 2 - session.getInterruptVotes().size()) + "票").queue();
session.getInterruptVotes().add(event.getUser().getIdLong());
- if (session.getInterruptVotes().size() > (gameSession.getPlayers().size() / 2)) {
+ WerewolfHelper.webServer.broadcastSessionUpdate(gameSession);
+ if (session.getInterruptVotes().size() > (gameSession.fetchAlivePlayers().size() / 2)) {
List voterMentions = new LinkedList<>();
for (long voter : session.getInterruptVotes()) {
voterMentions.add("<@!" + voter + ">");
@@ -234,25 +243,29 @@ public void interruptSpeech(ButtonInteractionEvent event) {
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();
+ startTimer(event.getGuild(), event.getChannel().asTextChannel(),
+ Objects.requireNonNull(Objects.requireNonNull(event.getMember()).getVoiceState()).getChannel(),
+ (int) time.getSeconds());
+ }
+
+ public static void startTimer(Guild guild, TextChannel textChannel, AudioChannel voiceChannel, int seconds) {
Thread thread = new Thread(() -> {
- Message message = event.getChannel().sendMessage(time.getSeconds() + "秒的計時開始," + TimeFormat.TIME_LONG.after(time) + "後結束")
+ Message message = textChannel.sendMessage(seconds + "秒的計時開始," + TimeFormat.TIME_LONG.after(Duration.ofSeconds(seconds)) + "後結束")
.setComponents(ActionRow.of(Button.danger("terminateTimer", "強制結束計時"))).complete();
try {
- if (time.getSeconds() > 30) {
- Thread.sleep(time.toMillis() - 30000);
+ if (seconds > 30) {
+ Thread.sleep((seconds - 30) * 1000L);
try {
- Audio.play(Audio.Resource.TIMER_30S_REMAINING, Objects.requireNonNull(Objects.requireNonNull(Objects.requireNonNull(
- event.getGuild()).getMember(event.getUser())).getVoiceState()).getChannel());
- } catch (NullPointerException ignored) {
+ if (voiceChannel != null) Audio.play(Audio.Resource.TIMER_30S_REMAINING, voiceChannel);
+ } catch (Exception ignored) {
}
Thread.sleep(30000);
} else {
- Thread.sleep(time.toMillis());
+ Thread.sleep(seconds * 1000L);
}
try {
- Audio.play(Audio.Resource.TIMER_ENDED, Objects.requireNonNull(Objects.requireNonNull(Objects.requireNonNull(
- event.getGuild()).getMember(event.getUser())).getVoiceState()).getChannel());
- } catch (NullPointerException ignored) {
+ if (voiceChannel != null) Audio.play(Audio.Resource.TIMER_ENDED, voiceChannel);
+ } catch (Exception ignored) {
}
message.editMessage(message.getContentRaw() + " (已結束)").queue();
message.reply("計時結束").queue();
@@ -261,7 +274,7 @@ public void start(SlashCommandInteractionEvent event, @Option(value = "time", de
}
});
thread.start();
- timers.put(event.getChannel().getIdLong(), thread);
+ timers.put(textChannel.getIdLong(), thread);
}
@Subcommand(description = "開始自動發言流程")
@@ -274,21 +287,27 @@ public void auto(SlashCommandInteractionEvent event) {
event.getHook().editOriginal("已經在發言流程中,請先終止上一個流程再繼續").queue();
return;
}
- speechSessions.put(event.getGuild().getIdLong(), SpeechSession.builder()
- .guildId(event.getGuild().getIdLong())
- .channelId(event.getChannel().getIdLong())
+ startAutoSpeech(event.getGuild(), event.getChannel().asTextChannel(), session);
+ event.getHook().editOriginal(":white_check_mark:").queue();
+ }
+
+ public static void startAutoSpeech(Guild guild, TextChannel channel, Session session) {
+ if (speechSessions.containsKey(guild.getIdLong())) return;
+ speechSessions.put(guild.getIdLong(), SpeechSession.builder()
+ .guildId(guild.getIdLong())
+ .channelId(channel.getIdLong())
.session(session)
.build());
- for (Session.Player player : session.getPlayers().values()) {
+ for (Session.Player player : session.fetchAlivePlayers().values()) {
assert player.getUserId() != null;
try {
if (session.isMuteAfterSpeech())
- Objects.requireNonNull(Objects.requireNonNull(event.getGuild()).getMemberById(player.getUserId())).mute(true).queue();
- } catch (IllegalStateException ignored) {
+ Objects.requireNonNull(guild.getMemberById(player.getUserId())).mute(true).queue();
+ } catch (Exception ignored) {
}
if (player.isPolice()) {
- event.getHook().editOriginalEmbeds(new EmbedBuilder().setTitle("警長請選擇發言順序")
+ channel.sendMessageEmbeds(new EmbedBuilder().setTitle("警長請選擇發言順序")
.setDescription("警長尚未選擇順序")
.setColor(MsgUtils.getRandomColor()).build())
.setComponents(ActionRow.of(StringSelectMenu.create("selectOrder")
@@ -300,17 +319,17 @@ public void auto(SlashCommandInteractionEvent event) {
}
}
- List shuffledPlayers = new LinkedList<>(session.getPlayers().values());
+ List shuffledPlayers = new LinkedList<>(session.fetchAlivePlayers().values());
Collections.shuffle(shuffledPlayers);
Order order = Order.getRandomOrder();
- changeOrder(event.getGuild(), order, session.getPlayers().values(), shuffledPlayers.getFirst());
- event.getHook().editOriginalEmbeds(new EmbedBuilder().setTitle("找不到警長,自動抽籤發言順序")
+ changeOrder(guild, order, session.fetchAlivePlayers().values(), shuffledPlayers.getFirst());
+ channel.sendMessageEmbeds(new EmbedBuilder().setTitle("找不到警長,自動抽籤發言順序")
.setDescription("抽到的順序: 玩家" + shuffledPlayers.getFirst().getId() + order.toString())
.setColor(MsgUtils.getRandomColor()).build()).queue();
- speechSessions.get(event.getGuild().getIdLong()).next();
+ speechSessions.get(guild.getIdLong()).next();
- for (TextChannel channel : event.getGuild().getTextChannels()) {
- channel.sendMessage("⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯我是白天分隔線⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯").queue();
+ for (TextChannel c : guild.getTextChannels()) {
+ c.sendMessage("⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯我是白天分隔線⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯").queue();
}
}
@@ -321,35 +340,71 @@ public void interrupt(SlashCommandInteractionEvent event) {
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();
+ interrupt(event.getGuild().getIdLong());
event.getHook().editOriginal(":white_check_mark:").queue();
}
}
+ public static void interrupt(long guildId) {
+ if (speechSessions.containsKey(guildId)) {
+ SpeechSession speechSession = speechSessions.get(guildId);
+ Guild guild = WerewolfHelper.jda.getGuildById(guildId);
+ if (guild != null) {
+ TextChannel channel = guild.getTextChannelById(speechSession.getChannelId());
+ if (channel != null) {
+ channel.sendMessage("法官已強制終止發言流程").queue();
+ }
+ }
+ speechSession.getOrder().clear();
+ speechSession.interrupt();
+ }
+ }
+
+ public static void skip(long guildId) {
+ if (speechSessions.containsKey(guildId)) {
+ SpeechSession speechSession = speechSessions.get(guildId);
+ Guild guild = WerewolfHelper.jda.getGuildById(guildId);
+ if (guild != null) {
+ TextChannel channel = guild.getTextChannelById(speechSession.getChannelId());
+ if (channel != null) {
+ channel.sendMessage("法官已強制該玩家下台").queue();
+ }
+ }
+ speechSession.next();
+ }
+ }
+
@Subcommand(description = "解除所有人的靜音")
public void unmute_all(SlashCommandInteractionEvent event) {
if (!CmdUtils.isAdmin(event)) return;
- for (Member member : Objects.requireNonNull(event.getGuild()).getMembers()) {
+ unmuteAll(event.getGuild());
+ event.reply(":white_check_mark:").queue();
+ }
+
+ public static void unmuteAll(Guild guild) {
+ for (Member member : guild.getMembers()) {
try {
member.mute(false).queue();
} catch (IllegalStateException ignored) {
}
}
- 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()) {
+ muteAll(event.getGuild());
+ event.reply(":white_check_mark:").queue();
+ }
+
+ public static void muteAll(Guild guild) {
+ for (Member member : guild.getMembers()) {
try {
if (member.getPermissions().contains(Permission.ADMINISTRATOR)) continue;
member.mute(true).queue();
} catch (IllegalStateException ignored) {
}
}
- event.reply(":white_check_mark:").queue();
}
public enum Order {
@@ -386,12 +441,17 @@ public static class SpeechSession {
private Long lastSpeaker;
@Nullable
private Runnable finishedCallback;
+ @Builder.Default
+ private long currentSpeechEndTime = 0;
+ @Builder.Default
+ private int totalSpeechTime = 0;
public void interrupt() {
if (speakingThread != null) {
speakingThread.interrupt();
}
speechSessions.remove(guildId);
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
}
public void next() {
@@ -425,10 +485,15 @@ public void next() {
return;
}
final Session.Player player = order.getFirst();
+ lastSpeaker = player.getUserId();
+ int time = player.isPolice() ? 210 : 180;
+ totalSpeechTime = time;
+ currentSpeechEndTime = System.currentTimeMillis() + (time * 1000L);
+ order.removeFirst();
+ WerewolfHelper.webServer.broadcastSessionUpdate(session);
+
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) {
@@ -441,7 +506,7 @@ public void next() {
)).complete();
AudioChannel channel = guild.getVoiceChannelById(session.getCourtVoiceChannelId());
try {
- Thread.sleep((time - 30) * 1000);
+ Thread.sleep((time - 30) * 1000L);
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);
@@ -457,7 +522,6 @@ public void next() {
}
});
speakingThread.start();
- order.removeFirst();
}
}
}
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..67e3403
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/database/documents/AuthSession.java
@@ -0,0 +1,37 @@
+package dev.robothanzo.werewolf.database.documents;
+
+import com.mongodb.client.MongoCollection;
+import dev.robothanzo.werewolf.database.Database;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+import com.mongodb.client.model.IndexOptions;
+import com.mongodb.client.model.Indexes;
+
+import static com.mongodb.client.model.Filters.eq;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AuthSession {
+ private String sessionId;
+ private String userId;
+ private String username;
+ private String discriminator;
+ private String avatar;
+ private long guildId;
+ private String role;
+ private Date createdAt;
+
+ 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..ac2e30e
--- /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..ca57ed8 100644
--- a/src/main/java/dev/robothanzo/werewolf/database/documents/Session.java
+++ b/src/main/java/dev/robothanzo/werewolf/database/documents/Session.java
@@ -6,8 +6,12 @@
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import java.text.DecimalFormat;
+import java.text.NumberFormat;
import java.util.*;
+import static com.mongodb.client.model.Filters.eq;
+
@Data
@Builder
@NoArgsConstructor
@@ -30,6 +34,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 +75,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())
@@ -86,9 +107,9 @@ public Result hasEnded(@Nullable String simulateRoleRemoval) {
return Result.VILLAGERS_DIED;
}
if (policeOnGood)
- villagers += 0.5;
+ villagers += 0.5f;
if (policeOnWolf)
- wolves += 0.5;
+ 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 +133,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 +152,15 @@ 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 +174,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/listeners/ButtonListener.java b/src/main/java/dev/robothanzo/werewolf/listeners/ButtonListener.java
index 553710f..b379680 100644
--- a/src/main/java/dev/robothanzo/werewolf/listeners/ButtonListener.java
+++ b/src/main/java/dev/robothanzo/werewolf/listeners/ButtonListener.java
@@ -49,7 +49,7 @@ public void onButtonInteraction(@NotNull ButtonInteractionEvent event) {
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;
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/server/SessionAPI.java b/src/main/java/dev/robothanzo/werewolf/server/SessionAPI.java
new file mode 100644
index 0000000..6f8fd24
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/server/SessionAPI.java
@@ -0,0 +1,1093 @@
+package dev.robothanzo.werewolf.server;
+
+import com.mongodb.client.model.Updates;
+import dev.robothanzo.werewolf.commands.Speech;
+import dev.robothanzo.werewolf.database.documents.Session;
+import dev.robothanzo.werewolf.utils.CmdUtils;
+import dev.robothanzo.werewolf.utils.DiscordActionRunner;
+import dev.robothanzo.werewolf.utils.DiscordActionRunner.ActionTask;
+import dev.robothanzo.werewolf.utils.MsgUtils;
+import lombok.extern.slf4j.Slf4j;
+import net.dv8tion.jda.api.EmbedBuilder;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.Permission;
+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.Role;
+
+import java.util.*;
+import java.util.function.Consumer;
+
+import static com.mongodb.client.model.Filters.eq;
+
+/**
+ * Helper class to convert Session objects to JSON for API
+ * and trigger existing command logic
+ */
+@Slf4j
+public class SessionAPI {
+
+ /**
+ * Convert Session to JSON format for frontend
+ */
+ public static Map toJSON(Session session, JDA jda) {
+ Map json = new LinkedHashMap<>();
+
+ 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, jda));
+
+ // Add speech info if available
+ if (Speech.speechSessions.containsKey(session.getGuildId())) {
+ Speech.SpeechSession speechSession = Speech.speechSessions.get(session.getGuildId());
+ Map speechJson = new LinkedHashMap<>();
+
+ List orderIds = new ArrayList<>();
+ for (Session.Player p : speechSession.getOrder()) {
+ orderIds.add(String.valueOf(p.getId()));
+ }
+ speechJson.put("order", orderIds);
+
+ if (speechSession.getLastSpeaker() != null) {
+ // Find player by user ID to get internal ID
+ 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 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 LinkedHashMap<>();
+ long gid = session.getGuildId();
+
+ boolean allowEnroll = dev.robothanzo.werewolf.commands.Poll.Police.allowEnroll.getOrDefault(gid, false);
+ boolean allowUnEnroll = dev.robothanzo.werewolf.commands.Poll.Police.allowUnEnroll.getOrDefault(gid, false);
+ policeJson.put("allowEnroll", allowEnroll);
+ policeJson.put("allowUnEnroll", allowUnEnroll);
+
+ List candidatesList = new ArrayList<>();
+ if (dev.robothanzo.werewolf.commands.Poll.Police.candidates.containsKey(gid)) {
+ for (dev.robothanzo.werewolf.commands.Poll.Candidate c : dev.robothanzo.werewolf.commands.Poll.Police.candidates.get(gid).values()) {
+ if (!c.isQuit()) {
+ candidatesList.add(String.valueOf(c.getPlayer().getId()));
+ }
+ }
+ }
+ policeJson.put("candidates", candidatesList);
+ json.put("police", policeJson);
+
+ // Add guild info if available
+ if (jda != null) {
+ Guild guild = jda.getGuildById(session.getGuildId());
+ if (guild != null) {
+ json.put("guildName", guild.getName());
+ json.put("guildIcon", guild.getIconUrl());
+ }
+ }
+
+ // Add logs
+ List> logsJson = new ArrayList<>();
+ if (session.getLogs() != null) {
+ for (Session.LogEntry log : session.getLogs()) {
+ Map logJson = new LinkedHashMap<>();
+ logJson.put("id", log.getId());
+ logJson.put("timestamp", formatTimestamp(log.getTimestamp()));
+ logJson.put("type", log.getType().getSeverity()); // Use severity for UI type
+ 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;
+ }
+
+ /**
+ * Format timestamp to HH:mm:ss
+ */
+ private static 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);
+ }
+
+ /**
+ * Convert players map to JSON array
+ */
+ public static List> playersToJSON(Session session, JDA jda) {
+ List> players = new ArrayList<>();
+
+ for (Map.Entry entry : session.getPlayers().entrySet()) {
+ Session.Player player = entry.getValue();
+ Map playerJson = new 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;
+ // Add Discord user info if available
+ 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()); // Generated nickname
+ playerJson.put("username", member.getUser().getName()); // Discord username
+ 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);
+ }
+
+ // Sort by player ID
+ 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;
+ }
+
+ /**
+ * Assign roles to players (matching /player assign logic)
+ */
+ public static void assignRoles(long guildId, JDA jda, Consumer statusLogger, Consumer progressCallback) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ if (session.isHasAssignedRoles()) {
+ throw new Exception("Roles already assigned");
+ }
+
+ int totalPlayers = session.getPlayers().size();
+ System.out.println("Starting role assignment for guild " + guildId + " with " + totalPlayers + " players");
+
+ if (progressCallback != null) progressCallback.accept(5);
+ if (statusLogger != null) statusLogger.accept("正在掃描伺服器玩家...");
+
+ Guild guild = jda != null ? jda.getGuildById(guildId) : null;
+ if (guild == null) {
+ throw new Exception("Guild not found");
+ }
+
+ List pending = new LinkedList<>();
+ for (Member member : guild.getMembers()) {
+ if ((!member.getUser().isBot()) &&
+ (!member.getRoles().contains(guild.getRoleById(session.getJudgeRoleId()))) &&
+ (!member.getRoles().contains(guild.getRoleById(session.getSpectatorRoleId())))) {
+ pending.add(member);
+ }
+ }
+
+ if (progressCallback != null) progressCallback.accept(10);
+ if (statusLogger != null) statusLogger.accept("正在驗證玩家與身分數量...");
+
+ Collections.shuffle(pending, new Random());
+ if (pending.size() != session.getPlayers().size()) {
+ throw new Exception("玩家數量不符合設定值。請確認是否已給予旁觀者應有之身分(使用 `/player died`),或檢查 `/server set players` 設定的人數。\n(待分配: " + pending.size() + ", 需要: " + session.getPlayers().size() + ")");
+ }
+
+ int rolesPerPlayer = session.isDoubleIdentities() ? 2 : 1;
+ if (pending.size() != (session.getRoles().size() / rolesPerPlayer)) {
+ throw new Exception("玩家身分數量不符合身分清單數量。請確認是否正確啟用雙身分模式,並檢查 `/server roles list`。\n(目前玩家: " + pending.size() + ", 身分總數: " + session.getRoles().size() + ")");
+ }
+
+ List roles = new ArrayList<>(session.getRoles());
+ Collections.shuffle(roles);
+
+ int gaveJinBaoBao = 0;
+ int processedCount = 0;
+ totalPlayers = session.getPlayers().size();
+
+ if (statusLogger != null) statusLogger.accept("正在分配身分並更新伺服器狀態...");
+
+ List playersList = new ArrayList<>(session.getPlayers().values());
+ playersList.sort(Comparator.comparingInt(Session.Player::getId));
+
+ List tasks = new ArrayList<>();
+
+ for (Session.Player player : playersList) {
+ Member member = pending.get(player.getId() - 1);
+
+ // 1. Prepare Discord Role Task
+ Role playerRole = guild.getRoleById(player.getRoleId());
+ if (playerRole != null) {
+ tasks.add(new ActionTask(guild.addRoleToMember(member, playerRole),
+ "已套用身分組: " + playerRole.getName() + " 給 " + member.getEffectiveName()));
+ }
+
+ // 2. Logic for role selection (JinBaoBao, etc.)
+ List rs = new LinkedList<>();
+ boolean isJinBaoBao = false;
+ rs.add(roles.removeFirst());
+
+ if (rs.getFirst().equals("白癡")) {
+ player.setIdiot(true);
+ }
+
+ if (rs.getFirst().equals("平民") && gaveJinBaoBao == 0 && session.isDoubleIdentities()) {
+ rs = new LinkedList<>(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 (String 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("平民")) {
+ isJinBaoBao = true;
+ gaveJinBaoBao++;
+ }
+ }
+ if (rs.getFirst().contains("狼")) {
+ Collections.reverse(rs);
+ }
+ if (shouldRemove)
+ roles.removeFirst();
+ }
+
+ player.setJinBaoBao(isJinBaoBao && session.isDoubleIdentities());
+ player.setRoles(rs);
+ player.setDeadRoles(new ArrayList<>());
+ player.setUserId(member.getIdLong());
+
+ // 3. Prepare Nickname Task (Now that roles are set and player is 'alive')
+ String newNickname = player.getNickname();
+ if (!member.getEffectiveName().equals(newNickname)) {
+ tasks.add(new ActionTask(member.modifyNickname(newNickname),
+ "已更新暱稱: " + newNickname));
+ }
+
+ final List finalRs = rs;
+ if (statusLogger != null)
+ statusLogger.accept(" - 已分配身分: " + String.join(", ", finalRs) + (player.isJinBaoBao() ? " (金寶寶)" : ""));
+
+ // 4. Send Channel Message
+ net.dv8tion.jda.api.entities.channel.concrete.TextChannel playerChannel = guild.getTextChannelById(player.getChannelId());
+ if (playerChannel != null) {
+ EmbedBuilder embed = new EmbedBuilder()
+ .setTitle("你抽到的身分是 (若為狼人或金寶寶請使用自己的頻道來和隊友討論及確認身分)")
+ .setDescription(String.join("、", rs) + (player.isJinBaoBao() ? " (金寶寶)" : "") +
+ (player.isDuplicated() ? " (複製人)" : ""))
+ .setColor(MsgUtils.getRandomColor());
+
+ var action = playerChannel.sendMessageEmbeds(embed.build());
+
+ if (session.isDoubleIdentities()) {
+ action.setComponents(ActionRow.of(Button.primary("changeRoleOrder", "更換身分順序 (請在收到身分後全分聽完再使用,逾時不候)")));
+ CmdUtils.schedule(() -> {
+ Session.fetchCollection().updateOne(eq("guildId", guild.getIdLong()),
+ Updates.set("players." + player.getId() + ".rolePositionLocked", true));
+ net.dv8tion.jda.api.entities.channel.concrete.TextChannel ch = guild.getTextChannelById(player.getChannelId());
+ if (ch != null) ch.sendMessage("身分順序已鎖定").queue();
+ }, 120000);
+ }
+ tasks.add(new ActionTask(action, "已發送私密頻道訊息予 " + member.getEffectiveName()));
+ }
+
+ // Sync players map in memory & persistence for safety
+ session.getPlayers().put(String.valueOf(player.getId()), player);
+ Session.fetchCollection().updateOne(eq("guildId", guildId),
+ Updates.set("players." + player.getId(), player));
+ }
+
+ if (tasks.size() > 0) {
+ if (statusLogger != null) statusLogger.accept("正在執行 Discord 變更 (共 " + tasks.size() + " 項)...");
+ DiscordActionRunner.runActions(tasks, statusLogger, progressCallback, 10, 95, 60);
+ } else {
+ if (statusLogger != null) statusLogger.accept("沒有偵測到需要執行的 Discord 變更。");
+ if (progressCallback != null) progressCallback.accept(95);
+ }
+
+ // Add log entry
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.ROLE_ASSIGNED,
+ "身分分配完成", null);
+
+ // Final Update session flags and logs
+ Session.fetchCollection().updateOne(
+ eq("guildId", guildId),
+ Updates.combine(
+ Updates.set("hasAssignedRoles", true),
+ Updates.set("logs", session.getLogs())
+ )
+ );
+
+ if (progressCallback != null) progressCallback.accept(100);
+ if (statusLogger != null) statusLogger.accept("身分分配完成!");
+ }
+
+ /**
+ * Mark a player as dead (uses existing command logic)
+ */
+ public static void markPlayerDead(long guildId, long userId, boolean lastWords, JDA jda) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ if (jda == null) {
+ throw new Exception("JDA instance is required for this operation");
+ }
+
+ Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ throw new Exception("Guild not found");
+ }
+
+ Member member = guild.getMemberById(userId);
+ if (member == null) {
+ // Try to retrieve if not in cache (though unlikely for active player)
+ try {
+ member = guild.retrieveMemberById(userId).complete();
+ } catch (Exception e) {
+ throw new Exception("Member not found in guild: " + e.getMessage());
+ }
+ }
+
+ // Use the existing command logic to ensure consistency (messages, game end checks, soft kill)
+ // isExpelled is false because this is an admin kill / direct kill, not necessarily a vote expulsion
+ boolean success = dev.robothanzo.werewolf.commands.Player.playerDied(session, member, lastWords, false);
+
+ if (!success) {
+ throw new Exception("Failed to mark player as dead (Player might already be dead)");
+ }
+ }
+
+ /**
+ * Revive a specific role for a player
+ */
+ public static void revivePlayerRole(long guildId, long userId, String role, JDA jda) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ if (jda == null) {
+ throw new Exception("JDA instance is required for this operation");
+ }
+
+ Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ throw new Exception("Guild not found");
+ }
+
+ Member member = guild.getMemberById(userId);
+ if (member == null) {
+ try {
+ member = guild.retrieveMemberById(userId).complete();
+ } catch (Exception e) {
+ throw new Exception("Member not found in guild: " + e.getMessage());
+ }
+ }
+
+ boolean success = dev.robothanzo.werewolf.commands.Player.playerRevived(session, member, role);
+
+ if (!success) {
+ throw new Exception("Failed to revive role (Role might not be dead or found)");
+ }
+ }
+
+ /**
+ * Revive all dead roles for a player
+ */
+ public static void revivePlayer(long guildId, long userId, JDA jda) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ 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) {
+ try {
+ member = guild.retrieveMemberById(userId).complete();
+ } catch (Exception e) {
+ throw new Exception("Member not found in guild: " + e.getMessage());
+ }
+ }
+
+ // Find player in session to get dead roles
+ Session.Player targetPlayer = null;
+ for (Session.Player 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");
+ }
+
+ // Revive all dead roles
+ // Create a copy to avoid concurrent modification during iteration if logic changes
+ List rolesToRevive = new ArrayList<>(targetPlayer.getDeadRoles());
+ for (String role : rolesToRevive) {
+ dev.robothanzo.werewolf.commands.Player.playerRevived(session, member, role);
+ // Refresh session after each revive as playerRevived updates DB
+ session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ }
+
+ // Force nickname update after mass revival just in case
+ if (targetPlayer != null) {
+ targetPlayer.updateNickname(member);
+ }
+ }
+
+ /**
+ * Set a player as police
+ */
+ public static void setPolice(long guildId, long userId, JDA jda) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ if (jda == null) {
+ throw new Exception("JDA instance is required");
+ }
+
+ Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ throw new Exception("Guild not found");
+ }
+
+ // Remove police from all players and update nickname
+ for (Session.Player player : session.getPlayers().values()) {
+ if (player.isPolice()) {
+ player.setPolice(false);
+ if (player.getUserId() != null) {
+ Member member = guild.getMemberById(player.getUserId());
+ if (member != null) player.updateNickname(member);
+ }
+ }
+ }
+
+ // Set new police
+ Session.Player targetPlayer = null;
+ for (Session.Player 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");
+ }
+
+ // Update new police nickname
+ if (targetPlayer.getUserId() != null) {
+ Member member = guild.getMemberById(targetPlayer.getUserId());
+ if (member != null) {
+ targetPlayer.updateNickname(member);
+
+ // Send message to court channel
+ net.dv8tion.jda.api.entities.channel.concrete.TextChannel channel = guild.getTextChannelById(session.getCourtTextChannelId());
+ if (channel != null) {
+ channel.sendMessage(":white_check_mark: 警徽已移交給 " + member.getAsMention()).queue();
+ }
+ }
+ }
+
+ // Update session
+ Session.fetchCollection().updateOne(
+ eq("guildId", guildId),
+ Updates.set("players", session.getPlayers())
+ );
+ }
+
+ /**
+ * Add role to session's role list
+ */
+ public static void addRole(long guildId, String role, int amount) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ List roles = new ArrayList<>(session.getRoles());
+ for (int i = 0; i < amount; i++) {
+ roles.add(role);
+ }
+
+ Session.fetchCollection().updateOne(
+ eq("guildId", guildId),
+ Updates.set("roles", roles)
+ );
+ }
+
+ /**
+ * Remove role from session's role list
+ */
+ public static void removeRole(long guildId, String role, int amount) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ List roles = new ArrayList<>(session.getRoles());
+ for (int i = 0; i < amount; i++) {
+ roles.remove(role);
+ }
+
+ Session.fetchCollection().updateOne(
+ eq("guildId", guildId),
+ Updates.set("roles", roles)
+ );
+ }
+
+ /**
+ * Update session settings
+ */
+ public static void updateSettings(long guildId, Map settings) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ List updates = new ArrayList<>();
+
+ if (settings.containsKey("doubleIdentities")) {
+ updates.add(Updates.set("doubleIdentities", settings.get("doubleIdentities")));
+ }
+
+ if (settings.containsKey("muteAfterSpeech")) {
+ updates.add(Updates.set("muteAfterSpeech", settings.get("muteAfterSpeech")));
+ }
+
+ if (!updates.isEmpty()) {
+ Session.fetchCollection().updateOne(
+ eq("guildId", guildId),
+ Updates.combine(updates)
+ );
+ }
+ }
+
+ /**
+ * Update a player's roles
+ */
+ public static void updatePlayerRoles(long guildId, String playerId, List newRoles, JDA jda) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ Session.Player targetPlayer = session.getPlayers().get(playerId);
+
+ if (targetPlayer == null) {
+ throw new Exception("Player not found");
+ }
+
+ List finalRoles = new ArrayList<>(newRoles);
+
+ // 1. Handle Duplicated (Copycat) logic
+ boolean isDuplicated = newRoles.contains("複製人");
+ targetPlayer.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));
+ }
+ }
+
+ // 2. Handle Idiot logic
+ boolean isIdiot = finalRoles.contains("白癡");
+ targetPlayer.setIdiot(isIdiot);
+
+ // 3. Handle Jin Bao Bao (Golden Baby) logic
+ // Two Villagers (平民) -> Jin Bao Bao
+ boolean isJinBaoBao = false;
+ if (session.isDoubleIdentities() && finalRoles.size() == 2) {
+ if (finalRoles.get(0).equals("平民") && finalRoles.get(1).equals("平民")) {
+ isJinBaoBao = true;
+ }
+ }
+ targetPlayer.setJinBaoBao(isJinBaoBao);
+
+ targetPlayer.setRoles(finalRoles);
+
+ // Update session
+ Session.fetchCollection().updateOne(
+ eq("guildId", guildId),
+ Updates.set("players", session.getPlayers())
+ );
+
+ // Notify user in their channel
+ if (jda != null && targetPlayer.getChannelId() != 0) {
+ Guild guild = jda.getGuildById(guildId);
+ if (guild != null) {
+ net.dv8tion.jda.api.entities.channel.concrete.TextChannel channel = guild.getTextChannelById(targetPlayer.getChannelId());
+ if (channel != null) {
+ channel.sendMessage("法官已將你的身份更改為: " + String.join(", ", newRoles)).queue();
+ }
+ }
+ }
+ }
+
+ /**
+ * Switch the order of roles for a player (for double identity)
+ */
+ public static void switchRoleOrder(long guildId, long userId, JDA jda) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ Session.Player targetPlayer = null;
+ for (Session.Player p : session.getPlayers().values()) {
+ if (p.getUserId() != null && p.getUserId() == userId) {
+ targetPlayer = p;
+ break;
+ }
+ }
+
+ if (targetPlayer == null) {
+ throw new Exception("Player not found");
+ }
+
+ // check lock
+ if (targetPlayer.isRolePositionLocked()) {
+ throw new Exception("Role position is locked for this player");
+ }
+
+ if (targetPlayer.getRoles() == null || targetPlayer.getRoles().size() < 2) {
+ throw new Exception("Player does not have multiple roles to switch");
+ }
+
+ // Swap the first two roles
+ Collections.swap(targetPlayer.getRoles(), 0, 1);
+
+ // Update session
+ Session.fetchCollection().updateOne(
+ eq("guildId", guildId),
+ Updates.set("players", session.getPlayers())
+ );
+
+ // Notify user if needed (optional)
+ if (jda != null && targetPlayer.getChannelId() != 0) {
+ Guild guild = jda.getGuildById(guildId);
+ if (guild != null) {
+ net.dv8tion.jda.api.entities.channel.concrete.TextChannel channel = guild.getTextChannelById(targetPlayer.getChannelId());
+ if (channel != null) {
+ channel.sendMessage("你已交換了角色順序,現在主要角色為: " + targetPlayer.getRoles().get(0)).queue();
+ }
+ }
+ }
+ }
+
+ /**
+ * Set role position lock for a player
+ */
+ public static void setRolePositionLock(long guildId, String playerId, boolean locked) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ Session.Player targetPlayer = session.getPlayers().get(playerId);
+ if (targetPlayer == null) {
+ throw new Exception("Player not found");
+ }
+
+ targetPlayer.setRolePositionLocked(locked);
+
+ Session.fetchCollection().updateOne(
+ eq("guildId", guildId),
+ Updates.set("players", session.getPlayers())
+ );
+ }
+
+ /**
+ * Start the game (Log game start)
+ */
+ public static void startGame(long guildId) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.GAME_STARTED,
+ "遊戲正式開始!", null);
+
+ Session.fetchCollection().updateOne(
+ eq("guildId", guildId),
+ Updates.set("logs", session.getLogs())
+ );
+ }
+
+ /**
+ * Get all text channel members (potential judges)
+ */
+ public static List> getGuildMembers(long guildId, JDA jda) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ 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 ArrayList<>();
+
+ // This relies on the member cache. Ensure the bot has GUILD_MEMBERS intent and cache is built.
+ for (Member member : guild.getMembers()) {
+ if (member.getUser().isBot()) continue;
+
+ Map memberMap = new 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);
+
+ // Also check if they are an active player
+ boolean isPlayer = session.getPlayers().values().stream()
+ .anyMatch(p -> p.getUserId() != null && p.getUserId() == member.getIdLong() && p.isAlive());
+ memberMap.put("isPlayer", isPlayer);
+
+ membersJson.add(memberMap);
+ }
+
+ // Sort judges first, then alphabetical
+ 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;
+ }
+
+ /**
+ * Reset session to initial state
+ */
+ /**
+ * Update a user's role (Judge/Spectator) in Discord
+ */
+ public static void updateUserRole(long guildId, long userId, String roleName, JDA jda) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ 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) {
+ try {
+ member = guild.retrieveMemberById(userId).complete();
+ } catch (Exception e) {
+ throw new Exception("Member not found in guild");
+ }
+ }
+
+ Role judgeRole = guild.getRoleById(session.getJudgeRoleId());
+ if (judgeRole == null) {
+ throw new Exception("Judge role not configured or found in guild");
+ }
+
+ if ("JUDGE".equalsIgnoreCase(roleName)) {
+ // Add Judge role
+ try {
+ guild.addRoleToMember(member, judgeRole).complete();
+ System.out.println("Successfully added Judge role (" + judgeRole.getName() + ") to " + member.getEffectiveName());
+ } catch (Exception e) {
+ log.error("Failed to add Judge role", e);
+ throw new Exception("Failed to add Judge role: " + e.getMessage());
+ }
+
+ // Optionally remove Spectator role if needed, but Judges can usually see everything anyway.
+
+ } else if ("SPECTATOR".equalsIgnoreCase(roleName) || "DEMOTE".equalsIgnoreCase(roleName)) {
+ // Remove Judge role
+ try {
+ guild.removeRoleFromMember(member, judgeRole).complete();
+ System.out.println("Successfully removed Judge role from " + member.getEffectiveName());
+ } catch (Exception e) {
+ log.error("Failed to remove Judge role", e);
+ throw new Exception("Failed to remove Judge role: " + e.getMessage());
+ }
+
+ // Ensure they have Spectator role if they are not playing
+ Role spectatorRole = guild.getRoleById(session.getSpectatorRoleId());
+ if (spectatorRole != null) {
+ try {
+ guild.addRoleToMember(member, spectatorRole).complete();
+ System.out.println("Added spectator role to " + member.getEffectiveName());
+ } catch (Exception e) {
+ log.error("Failed to add spectator role", e);
+ // Don't fail the whole request if just adding spectator fails, but maybe worth consistent behavior.
+ }
+ }
+ } else {
+ throw new Exception("Unknown role type: " + roleName);
+ }
+ }
+
+ /**
+ * Reset session to initial state
+ */
+ public static void resetSession(long guildId, JDA jda, java.util.function.Consumer statusLogger, java.util.function.Consumer progressCallback) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ progressCallback.accept(0);
+ statusLogger.accept("正在連線至 Discord...");
+
+ Guild guild = jda != null ? jda.getGuildById(guildId) : null;
+ if (guild == null) {
+ throw new Exception("Guild not found");
+ }
+
+ progressCallback.accept(5);
+ statusLogger.accept("正在掃描需要清理的身分組...");
+
+ // Collect all tasks to perform
+ List tasks = new ArrayList<>();
+
+ // 1. Participant Cleanup (Direct targeted removal)
+ Role spectatorRole = guild.getRoleById(session.getSpectatorRoleId());
+
+ for (Session.Player player : session.getPlayers().values()) {
+ Long currentUserId = player.getUserId();
+
+ // 1. Reset player state in memory
+ player.setUserId(null);
+ player.setRoles(new ArrayList<>());
+ player.setDeadRoles(new ArrayList<>());
+ player.setPolice(false);
+ player.setIdiot(false);
+ player.setJinBaoBao(false);
+ player.setDuplicated(false);
+ player.setRolePositionLocked(false);
+
+ // 2. Queue Discord cleanup tasks if there was a user
+ if (currentUserId != null) {
+ net.dv8tion.jda.api.entities.Member member = guild.getMemberById(currentUserId);
+ if (member != null) {
+ if (spectatorRole != null) {
+ tasks.add(new ActionTask(guild.removeRoleFromMember(member, spectatorRole),
+ "已移除 " + member.getEffectiveName() + " 的旁觀者身分組"));
+ }
+
+ Role playerRole = guild.getRoleById(player.getRoleId());
+ if (playerRole != null) {
+ tasks.add(new ActionTask(guild.removeRoleFromMember(member, playerRole),
+ "已移除玩家 " + player.getId() + " (" + member.getEffectiveName() + ") 的玩家身分組"));
+ }
+
+ if (member.getNickname() != null) {
+ tasks.add(new ActionTask(member.modifyNickname(null),
+ "已重置 " + member.getEffectiveName() + " 的暱稱"));
+ }
+ }
+ }
+ }
+
+ if (tasks.size() > 0) {
+ statusLogger.accept("正在執行 Discord 變更 (共 " + tasks.size() + " 項)...");
+ DiscordActionRunner.runActions(tasks, statusLogger, progressCallback, 5, 90, 30);
+ } else {
+ statusLogger.accept("沒有偵測到需要清理的 Discord 變更。");
+ progressCallback.accept(90);
+ }
+
+ statusLogger.accept("正在更新資料庫並清理日誌...");
+
+ // Clear logs
+ session.setLogs(new ArrayList<>());
+
+ // Reset game flags
+ session.setHasAssignedRoles(false);
+
+ // Add reset log
+ session.addLog(dev.robothanzo.werewolf.database.documents.LogType.GAME_RESET,
+ "遊戲已重置", null);
+
+ // Update session in database
+ Session.fetchCollection().updateOne(
+ eq("guildId", guildId),
+ Updates.combine(
+ Updates.set("players", session.getPlayers()),
+ Updates.set("hasAssignedRoles", false),
+ Updates.set("logs", session.getLogs())
+ )
+ );
+
+ progressCallback.accept(100);
+ statusLogger.accept("操作完成。");
+ }
+
+ /**
+ * Set the player count for the session (resizing game)
+ */
+ public static void setPlayerCount(long guildId, int count, JDA jda) throws Exception {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ throw new Exception("Session not found");
+ }
+
+ if (jda == null) {
+ throw new Exception("JDA instance is required");
+ }
+
+ Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ throw new Exception("Guild not found");
+ }
+
+ Map players = session.getPlayers();
+
+ // Remove players if count is smaller
+ for (Session.Player player : new LinkedList<>(players.values())) {
+ if (player.getId() > count) {
+ players.remove(String.valueOf(player.getId()));
+
+ try {
+ Role role = guild.getRoleById(player.getRoleId());
+ if (role != null) role.delete().queue();
+
+ net.dv8tion.jda.api.entities.channel.concrete.TextChannel channel = guild.getTextChannelById(player.getChannelId());
+ if (channel != null) channel.delete().queue();
+ } catch (Exception e) {
+ // Ignore errors during deletion (might already be deleted)
+ }
+ }
+ }
+
+ // Add players if count is larger
+ Role spectatorRole = guild.getRoleById(session.getSpectatorRoleId());
+
+ for (long i = players.size() + 1; i <= count; i++) {
+ Role role = guild.createRole()
+ .setColor(MsgUtils.getRandomColor())
+ .setHoisted(true)
+ .setName("玩家" + Session.Player.ID_FORMAT.format(i))
+ .complete();
+
+ net.dv8tion.jda.api.entities.channel.concrete.TextChannel channel = guild.createTextChannel("玩家" + Session.Player.ID_FORMAT.format(i))
+ .addPermissionOverride(spectatorRole != null ? spectatorRole : guild.getPublicRole(),
+ Permission.VIEW_CHANNEL.getRawValue(), Permission.MESSAGE_SEND.getRawValue())
+ .addPermissionOverride(role,
+ List.of(Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND),
+ List.of())
+ .addPermissionOverride(guild.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", guildId),
+ Updates.set("players", players)
+ );
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/server/WebServer.java b/src/main/java/dev/robothanzo/werewolf/server/WebServer.java
new file mode 100644
index 0000000..726aef0
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/server/WebServer.java
@@ -0,0 +1,1205 @@
+package dev.robothanzo.werewolf.server;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.mongodb.client.MongoCursor;
+import com.mongodb.client.model.Updates;
+import dev.robothanzo.werewolf.database.documents.AuthSession;
+import dev.robothanzo.werewolf.database.documents.Session;
+import io.javalin.Javalin;
+import io.javalin.http.Context;
+import io.javalin.http.ForbiddenResponse;
+import io.javalin.http.HandlerType;
+import io.javalin.http.UnauthorizedResponse;
+import io.javalin.websocket.WsContext;
+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 lombok.extern.slf4j.Slf4j;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.Permission;
+import net.dv8tion.jda.api.entities.Member;
+
+import java.time.Duration;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static com.mongodb.client.model.Filters.eq;
+
+@Slf4j
+public class WebServer implements Runnable {
+ // OAuth Configuration
+ 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 int port;
+ private final Set wsClients = ConcurrentHashMap.newKeySet();
+ private final ObjectMapper objectMapper;
+ private final DiscordOAuth discordOAuth;
+ private Javalin app;
+ private JDA jda;
+
+ public WebServer(int port) {
+ this.port = port;
+ this.objectMapper = new ObjectMapper();
+ this.objectMapper.registerModule(new JavaTimeModule());
+ this.discordOAuth = new DiscordOAuth(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, new String[]{"identify", "guilds", "guilds.members.read"});
+ }
+
+ public void setJDA(JDA jda) {
+ this.jda = jda;
+ }
+
+ @Override
+ public void run() {
+ start();
+ }
+
+ public void start() {
+ app = Javalin.create(config -> {
+ // Static file serving for dashboard (optional - use Vite dev server during development)
+ // Uncomment after building dashboard with: cd src/dashboard && yarn build
+ // config.staticFiles.add(staticFiles -> {
+ // staticFiles.hostedPath = "/";
+ // staticFiles.directory = "/dashboard";
+ // staticFiles.location = Location.CLASSPATH;
+ // });
+ }).start(port);
+
+ // Manual CORS configuration - set headers explicitly
+ app.before(ctx -> {
+ String origin = ctx.header("Origin");
+ if (origin != null) {
+ if (origin.equals("http://localhost:5173") ||
+ origin.equals("https://wolf.robothanzo.dev") ||
+ origin.equals("http://wolf.robothanzo.dev")) {
+ ctx.header("Access-Control-Allow-Origin", origin);
+ }
+ } else {
+ ctx.header("Access-Control-Allow-Origin", "*");
+ }
+
+ ctx.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
+ ctx.header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization");
+ ctx.header("Access-Control-Allow-Credentials", "true");
+ });
+
+ // Handle preflight OPTIONS requests
+ app.options("/*", ctx -> {
+ ctx.status(204);
+ });
+
+ log.info("Web server started on port {}", port);
+
+ // WebSocket endpoint
+ app.ws("/ws", ws -> {
+ ws.onConnect(ctx -> {
+ ctx.session.setIdleTimeout(Duration.ofMinutes(20));
+ wsClients.add(ctx);
+ log.info("WebSocket client connected. Total clients: {}", wsClients.size());
+ });
+
+ ws.onClose(ctx -> {
+ wsClients.remove(ctx);
+ log.info("WebSocket client disconnected. Total clients: {}", wsClients.size());
+ });
+
+ ws.onMessage(ctx -> {
+ try {
+ String message = ctx.message();
+ if (message.contains("\"type\":\"PING\"")) {
+ ctx.send("{\"type\":\"PONG\"}");
+ }
+ } catch (Exception e) {
+ log.error("WebSocket message handling error", e);
+ }
+ });
+
+ ws.onError(ctx -> {
+ log.error("WebSocket error", ctx.error());
+ wsClients.remove(ctx);
+ });
+ });
+
+ // Global Exception Handler
+ app.exception(Exception.class, (e, ctx) -> {
+ log.error("Unhandled exception: " + e.getMessage(), e);
+ ctx.status(500);
+ ctx.json(Map.of(
+ "success", false,
+ "error", e.getMessage() != null ? e.getMessage() : "Internal Server Error"
+ ));
+ });
+
+ // API Routes
+ setupApiRoutes();
+ }
+
+ // Helper method to determine user role
+ private String determineUserRole(String userId, long guildId) {
+ try {
+ net.dv8tion.jda.api.entities.Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ return "BLOCKED";
+ }
+
+ Member member = guild.getMemberById(userId);
+ if (member == null) {
+ return "BLOCKED";
+ }
+
+ // Check if user is admin (Judge)
+ if (member.hasPermission(Permission.ADMINISTRATOR)) {
+ return "JUDGE";
+ }
+
+ // Find session
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ return "SPECTATOR";
+ }
+
+ // Check if user is an active player
+ for (Session.Player player : session.getPlayers().values()) {
+ if (player.getUserId() != null && String.valueOf(player.getUserId()).equals(userId)) {
+ if (player.isAlive()) {
+ return "BLOCKED"; // Active players can't access
+ }
+ }
+ }
+
+ return "SPECTATOR"; // Dead players or non-players can spectate
+ } catch (Exception e) {
+ log.error("Error determining user role", e);
+ return "BLOCKED";
+ }
+ }
+
+ private void setupApiRoutes() {
+ // OAuth Login Endpoint
+ app.get("/api/auth/login", ctx -> {
+ String guildId = ctx.queryParam("guild_id");
+
+ // guild_id is now optional - if not provided, user logs in first then selects server
+ String state = guildId != null ? guildId : "no_guild";
+ String authUrl = discordOAuth.getAuthorizationURL(state);
+ ctx.redirect(authUrl);
+ });
+
+ // OAuth Callback Endpoint
+ app.get("/api/auth/callback", ctx -> {
+ String code = ctx.queryParam("code");
+ String state = ctx.queryParam("state");
+
+ log.info("OAuth callback received - code: {}, state: {}", code != null ? "present" : "null", state);
+
+ if (code == null || state == null) {
+ ctx.status(400).json(Map.of("success", false, "error", "Invalid callback"));
+ return;
+ }
+
+ try {
+ // Exchange code for token
+ log.info("Exchanging code for token...");
+ log.info("Using Redirect URI: {}", REDIRECT_URI);
+ log.info("Client ID present: {}", !CLIENT_ID.isEmpty());
+ log.info("Client Secret present: {}", !CLIENT_SECRET.isEmpty());
+ TokensResponse tokenResponse = discordOAuth.getTokens(code);
+ DiscordAPI discordAPI = new DiscordAPI(tokenResponse.getAccessToken());
+ User user = discordAPI.fetchUser();
+ log.info("User fetched: {} ({})", user.getUsername(), user.getId());
+
+ // Create session
+ String sessionId = UUID.randomUUID().toString();
+ log.info("Creating session with ID: {}", sessionId);
+
+ // If state is "no_guild", create session without role (role will be determined when accessing specific guild)
+ if ("no_guild".equals(state)) {
+ log.info("Creating session without guild (PENDING role)");
+ AuthSession authSession = AuthSession.builder()
+ .sessionId(sessionId)
+ .userId(user.getId())
+ .username(user.getUsername())
+ .discriminator(user.getDiscriminator())
+ .avatar(user.getAvatar())
+ .guildId(0)
+ .role("PENDING")
+ .createdAt(new java.util.Date())
+ .build();
+
+ AuthSession.fetchCollection().insertOne(authSession);
+
+ // Set cookie (7 days)
+ ctx.cookie("session_id", sessionId, 60 * 60 * 24 * 7);
+ log.info("Cookie set: session_id={}", sessionId);
+
+ // Redirect to server selector
+ String dashboardUrl = System.getenv().getOrDefault("DASHBOARD_URL", "http://localhost:5173");
+ log.info("Redirecting to: {}", dashboardUrl + "/");
+ ctx.redirect(dashboardUrl + "/");
+ } else {
+ log.info("Creating session with guild: {}", state);
+ // Legacy flow: state contains guild_id
+ long guildId = Long.parseLong(state);
+ String role = determineUserRole(user.getId(), guildId);
+
+ AuthSession authSession = AuthSession.builder()
+ .sessionId(sessionId)
+ .userId(user.getId())
+ .username(user.getUsername())
+ .discriminator(user.getDiscriminator())
+ .avatar(user.getAvatar())
+ .guildId(guildId)
+ .role(role)
+ .createdAt(new java.util.Date())
+ .build();
+
+ AuthSession.fetchCollection().insertOne(authSession);
+
+ // Set cookie (7 days)
+ ctx.cookie("session_id", sessionId, 60 * 60 * 24 * 7);
+ log.info("Cookie set: session_id={}", sessionId);
+
+ // Redirect to dashboard
+ String dashboardUrl = System.getenv().getOrDefault("DASHBOARD_URL", "http://localhost:5173");
+ log.info("Redirecting to: {}/server/{}", dashboardUrl, guildId);
+ ctx.redirect(dashboardUrl + "/server/" + guildId);
+ }
+ } catch (Exception e) {
+ log.error("OAuth callback error", e);
+ ctx.status(500).json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Get current user session
+ app.get("/api/auth/me", ctx -> {
+ String sessionId = ctx.cookie("session_id");
+ log.info("Auth check - session_id cookie: {}", sessionId);
+
+ UserSession session = getUserSession(ctx);
+ if (session == null) {
+ log.warn("No session found for session_id: {}", sessionId);
+ ctx.status(401).json(Map.of("success", false, "error", "Not authenticated"));
+ return;
+ }
+
+ log.info("Session found for user: {}", session.username());
+ ctx.json(Map.of(
+ "success", true,
+ "user", Map.of(
+ "userId", session.userId(),
+ "username", session.username(),
+ "avatar", session.avatar() != null ? session.avatar() : "",
+ "guildId", String.valueOf(session.guildId()),
+ "role", session.role()
+ )
+ ));
+ });
+
+ // Select a guild and update user's role
+ app.post("/api/auth/select-guild/{guildId}", ctx -> {
+ UserSession session = getUserSession(ctx);
+ if (session == null) {
+ ctx.status(401).json(Map.of("success", false, "error", "Not authenticated"));
+ return;
+ }
+
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ log.info("User {} selecting guild {}", session.userId(), guildId);
+
+ // Determine role for this guild
+ String role = determineUserRole(session.userId(), guildId);
+ log.info("Determined role for user {} in guild {}: {}", session.userId(), guildId, role);
+
+ // Update session with new role and guildId
+ UserSession updatedSession = new UserSession(
+ session.sessionId(),
+ session.userId(),
+ session.username(),
+ session.discriminator(),
+ session.avatar(),
+ guildId,
+ role,
+ System.currentTimeMillis()
+ );
+
+ // Update session in MongoDB
+ AuthSession.fetchCollection().updateOne(
+ eq("sessionId", session.sessionId()),
+ Updates.combine(
+ Updates.set("guildId", guildId),
+ Updates.set("role", role)
+ )
+ );
+ log.info("Updated session for user {} with role {} in guild {}", session.userId(), role, guildId);
+
+ ctx.json(Map.of(
+ "success", true,
+ "user", Map.of(
+ "userId", updatedSession.userId(),
+ "username", updatedSession.username(),
+ "avatar", updatedSession.avatar(),
+ "guildId", String.valueOf(updatedSession.guildId()),
+ "role", updatedSession.role()
+ )
+ ));
+ });
+
+ // Logout
+ app.post("/api/auth/logout", ctx -> {
+ String sessionId = ctx.cookie("session_id");
+ if (sessionId != null) {
+ AuthSession.fetchCollection().deleteOne(eq("sessionId", sessionId));
+ }
+ ctx.removeCookie("session_id");
+ ctx.json(Map.of("success", true));
+ });
+
+ // Permission middleware for API endpoints
+ app.before("/api/*", ctx -> {
+ String path = ctx.path();
+ log.info("Middleware check: {} {}", ctx.method(), path);
+
+ // 1. Allow all auth routes (login, callback, me, logout, select-guild)
+ if (path.startsWith("/api/auth/")) {
+ return;
+ }
+
+ // 2. Allow GET /api/sessions (the list of all available games)
+ // This is needed for the server selection page.
+ if (ctx.method() == HandlerType.GET && path.equals("/api/sessions")) {
+ return;
+ }
+
+ // 3. For everything else, require authentication
+ UserSession session = getUserSession(ctx);
+ if (session == null) {
+ log.warn("Unauthorized access attempt: {} {}", ctx.method(), path);
+ throw new UnauthorizedResponse("Not authenticated");
+ }
+
+ // 4. Guild-specific data protection
+ // Paths like /api/sessions/{guildId}/**
+ if (path.startsWith("/api/sessions/")) {
+ String[] parts = path.split("/");
+ // path is /api/sessions/{guildId}/...
+ // parts[0] is "", parts[1] is "api", parts[2] is "sessions", parts[3] is {guildId}
+ if (parts.length >= 4) {
+ String guildIdStr = parts[3];
+
+ // Enforce session matches the requested guild
+ if (!String.valueOf(session.guildId()).equals(guildIdStr)) {
+ log.warn("User {} tried to access guild {} while active in guild {}", session.userId(), guildIdStr, session.guildId());
+ throw new ForbiddenResponse("Please switch to this server first.");
+ }
+
+ // Enforce role permission for this guild
+ if (ctx.method() == HandlerType.GET) {
+ // GET requires JUDGE or SPECTATOR
+ if (!"JUDGE".equals(session.role()) && !"SPECTATOR".equals(session.role())) {
+ log.warn("User {} (role {}) denied GET on guild {}", session.userId(), session.role(), guildIdStr);
+ throw new ForbiddenResponse("Access denied. Active players cannot view management data.");
+ }
+ } else {
+ // Mutations (POST, PUT, DELETE) require JUDGE
+ if (!"JUDGE".equals(session.role())) {
+ log.warn("User {} (role {}) denied {} on guild {}", session.userId(), session.role(), ctx.method(), guildIdStr);
+ throw new ForbiddenResponse("Insufficient permissions. Only judges can modify game state.");
+ }
+ }
+ }
+ }
+ });
+
+ // List all sessions
+ app.get("/api/sessions", ctx -> {
+ List> sessions = new ArrayList<>();
+ try (MongoCursor cursor = Session.fetchCollection().find().iterator()) {
+ while (cursor.hasNext()) {
+ Session session = cursor.next();
+ sessions.add(SessionAPI.toJSON(session, jda));
+ }
+ }
+ ctx.json(Map.of("success", true, "data", sessions));
+ });
+
+ // Get specific session
+ app.get("/api/sessions/{guildId}", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+
+ if (session == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "找不到該伺服器的遊戲"));
+ return;
+ }
+
+ ctx.json(Map.of("success", true, "data", SessionAPI.toJSON(session, jda)));
+ });
+
+ // Get players for a session
+ app.get("/api/sessions/{guildId}/players", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+
+ if (session == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "找不到該伺服器的遊戲"));
+ return;
+ }
+
+ ctx.json(Map.of("success", true, "data", SessionAPI.playersToJSON(session, jda)));
+ });
+
+ // Get guild members (potential judges)
+ app.get("/api/sessions/{guildId}/members", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+
+ if (session == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "找不到該伺服器的遊戲"));
+ return;
+ }
+
+ try {
+ ctx.json(Map.of("success", true, "data", SessionAPI.getGuildMembers(guildId, jda)));
+ } catch (Exception e) {
+ log.error("Failed to fetch guild members", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Assign roles
+ app.post("/api/sessions/{guildId}/players/assign", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+
+ try {
+ SessionAPI.assignRoles(guildId, jda,
+ msg -> {
+ log.info("Broadcasting progress message for guild {}: {}", guildId, msg);
+ broadcastEvent("PROGRESS", Map.of("message", msg, "guildId", String.valueOf(guildId)));
+ },
+ p -> {
+ log.info("Broadcasting progress percentage for guild {}: {}%", guildId, p);
+ broadcastEvent("PROGRESS", Map.of("percent", p, "guildId", String.valueOf(guildId)));
+ }
+ );
+
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "角色已分配"));
+ } catch (Exception e) {
+ log.error("Failed to assign roles", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Reset session
+ app.post("/api/sessions/{guildId}/reset", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+
+ try {
+ SessionAPI.resetSession(guildId, jda,
+ msg -> {
+ log.info("Broadcasting reset progress for guild {}: {}", guildId, msg);
+ broadcastEvent("PROGRESS", Map.of("message", msg, "guildId", String.valueOf(guildId)));
+ },
+ p -> {
+ log.info("Broadcasting reset percentage for guild {}: {}%", guildId, p);
+ broadcastEvent("PROGRESS", Map.of("percent", p, "guildId", String.valueOf(guildId)));
+ }
+ );
+
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "遊戲已重置"));
+ } catch (Exception e) {
+ log.error("Failed to reset session", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Mark player as dead
+ app.post("/api/sessions/{guildId}/players/{userId}/died", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ long userId = Long.parseLong(ctx.pathParam("userId"));
+ String lastWordsParam = ctx.queryParam("lastWords");
+ boolean lastWords = Boolean.parseBoolean(lastWordsParam);
+
+ try {
+ SessionAPI.markPlayerDead(guildId, userId, lastWords, jda);
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "玩家已標記為死亡"));
+ } catch (Exception e) {
+ log.error("Failed to mark player as dead", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Revive player (all roles)
+ app.post("/api/sessions/{guildId}/players/{userId}/revive", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ long userId = Long.parseLong(ctx.pathParam("userId"));
+
+ try {
+ SessionAPI.revivePlayer(guildId, userId, jda);
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "玩家已完全復活"));
+ } catch (Exception e) {
+ log.error("Failed to revive player", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Revive specific role
+ app.post("/api/sessions/{guildId}/players/{userId}/revive-role", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ long userId = Long.parseLong(ctx.pathParam("userId"));
+ String role = ctx.queryParam("role");
+
+ if (role == null || role.isEmpty()) {
+ ctx.status(400);
+ ctx.json(Map.of("success", false, "error", "Role parameter is required"));
+ return;
+ }
+
+ try {
+ SessionAPI.revivePlayerRole(guildId, userId, role, jda);
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "角色已復活"));
+ } catch (Exception e) {
+ log.error("Failed to revive role", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Set police
+ app.post("/api/sessions/{guildId}/players/{userId}/police", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ long userId = Long.parseLong(ctx.pathParam("userId"));
+
+ try {
+ SessionAPI.setPolice(guildId, userId, jda);
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "警長已設定"));
+ } catch (Exception e) {
+ log.error("Failed to set police", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Update player roles
+ app.post("/api/sessions/{guildId}/players/{playerId}/roles", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ String playerId = ctx.pathParam("playerId");
+ List roles = ctx.bodyAsClass(List.class);
+
+ try {
+ SessionAPI.updatePlayerRoles(guildId, playerId, roles, jda);
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "玩家角色已更新"));
+ } catch (Exception e) {
+ log.error("Failed to update player roles", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+
+
+
+ // Update user Discord role (Judge/Spectator)
+ app.post("/api/sessions/{guildId}/players/{userId}/role", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ long userId = Long.parseLong(ctx.pathParam("userId"));
+ Map body = ctx.bodyAsClass(Map.class);
+ String role = body.get("role");
+
+ if (role == null) {
+ ctx.status(400);
+ ctx.json(Map.of("success", false, "error", "Role is required"));
+ return;
+ }
+
+ try {
+ SessionAPI.updateUserRole(guildId, userId, role, jda);
+ ctx.json(Map.of("success", true, "message", "使用者身分已更新"));
+ } catch (Exception e) {
+ log.error("Failed to update user role", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Switch role order
+ app.post("/api/sessions/{guildId}/players/{userId}/switch-role-order", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ long userId = Long.parseLong(ctx.pathParam("userId"));
+
+ try {
+ SessionAPI.switchRoleOrder(guildId, userId, jda);
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "角色順序已交換"));
+ } catch (Exception e) {
+ log.error("Failed to switch role order", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+
+ // Police Enroll Speech
+ app.post("/api/sessions/{guildId}/speech/police-enroll", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+
+ if (session == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "Session not found"));
+ return;
+ }
+
+ net.dv8tion.jda.api.entities.Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "Guild not found"));
+ return;
+ }
+
+ net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel channel =
+ guild.getTextChannelById(session.getCourtTextChannelId());
+
+ if (channel == null) {
+ ctx.status(400); // Bad Request / Configuration
+ ctx.json(Map.of("success", false, "error", "Court channel not found"));
+ return;
+ }
+
+ try {
+ dev.robothanzo.werewolf.commands.Poll.Police.startEnrollment(session, channel, null);
+ ctx.json(Map.of("success", true, "message", "警長參選流程已啟動"));
+ } catch (Exception e) {
+ log.error("Failed to start police enroll", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Set role position lock
+ app.post("/api/sessions/{guildId}/players/{userId}/role-lock", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ String userId = ctx.pathParam("userId");
+ String lockedParam = ctx.queryParam("locked");
+ boolean locked = Boolean.parseBoolean(lockedParam);
+
+ try {
+ SessionAPI.setRolePositionLock(guildId, userId, locked); // userId here is actually 'id' (player ID string key) based on existing API pattern for updatePlayerRoles?
+ // Wait, updatePlayerRoles uses "playerId" which maps to the key in players map (string).
+ // markPlayerDead uses "userId" (long) which seems to be Discord User ID?
+ // SessionAPI.setRolePositionLock takes String playerId.
+ // In setupApiRoutes, updatePlayerRoles uses pathParam("playerId") and passes it.
+ // Let's verify if markPlayerDead uses userId (Long) or playerId (String).
+ // markPlayerDead takes (long guildId, long userId, ...).
+ // updatePlayerRoles takes (long guildId, String playerId, ...).
+ // Session.players map key is likely String version of ID or something?
+ // Session.java: Map players.
+ // In playersToJSON: id is String.valueOf(player.getId()).
+ // Let's assume typical usage is player ID (int/string) for managing player entity, and UserID for discord actions.
+ // setRolePositionLock implementation I added takes String playerId. So I should use that.
+
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "角色鎖定狀態已更新"));
+ } catch (Exception e) {
+ log.error("Failed to set role position lock", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Get roles for session
+ app.get("/api/sessions/{guildId}/roles", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+
+ if (session == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "找不到該伺服器的遊戲"));
+ return;
+ }
+
+ ctx.json(Map.of("success", true, "data", session.getRoles()));
+ });
+
+ // Add role
+ app.post("/api/sessions/{guildId}/roles/add", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ String role = ctx.queryParam("role");
+ String amountParam = ctx.queryParam("amount");
+ int amount = amountParam != null ? Integer.parseInt(amountParam) : 1;
+
+ try {
+ SessionAPI.addRole(guildId, role, amount);
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "角色已新增"));
+ } catch (Exception e) {
+ log.error("Failed to add role", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Remove role
+ app.delete("/api/sessions/{guildId}/roles/{role}", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ String role = ctx.pathParam("role");
+ String amountParam = ctx.queryParam("amount");
+ int amount = amountParam != null ? Integer.parseInt(amountParam) : 1;
+
+ try {
+ SessionAPI.removeRole(guildId, role, amount);
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "角色已移除"));
+ } catch (Exception e) {
+ log.error("Failed to remove role", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+
+ // Start game
+ app.post("/api/sessions/{guildId}/start", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+
+ try {
+ SessionAPI.startGame(guildId);
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "遊戲已開始"));
+ } catch (Exception e) {
+ log.error("Failed to start game", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+
+ // Update settings
+ app.put("/api/sessions/{guildId}/settings", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+
+ try {
+ Map settings = ctx.bodyAsClass(Map.class);
+ SessionAPI.updateSettings(guildId, settings);
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "設定已更新"));
+ } catch (Exception e) {
+ log.error("Failed to update settings", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Set player count
+ app.post("/api/sessions/{guildId}/player-count", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ Map body = ctx.bodyAsClass(Map.class);
+
+ if (!body.containsKey("count")) {
+ ctx.status(400);
+ ctx.json(Map.of("success", false, "error", "Count is required"));
+ return;
+ }
+
+ int count = Integer.parseInt(body.get("count").toString());
+
+ try {
+ SessionAPI.setPlayerCount(guildId, count, jda);
+ Session updated = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (updated != null) {
+ broadcastSessionUpdate(updated);
+ }
+ ctx.json(Map.of("success", true, "message", "Player count updated"));
+ } catch (Exception e) {
+ log.error("Failed to update player count", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Start auto speech
+ app.post("/api/sessions/{guildId}/speech/auto", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ try {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "Session not found"));
+ return;
+ }
+
+ net.dv8tion.jda.api.entities.channel.concrete.TextChannel channel = jda.getTextChannelById(session.getCourtTextChannelId());
+ if (channel == null) {
+ ctx.status(400);
+ ctx.json(Map.of("success", false, "error", "Court channel not found"));
+ return;
+ }
+
+ net.dv8tion.jda.api.entities.Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "Guild not found"));
+ return;
+ }
+
+ dev.robothanzo.werewolf.commands.Speech.startAutoSpeech(guild, channel, session);
+ broadcastSessionUpdate(session);
+ ctx.json(Map.of("success", true, "message", "Speech started"));
+ } catch (Exception e) {
+ log.error("Failed to start speech", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Set speech order (Override)
+ app.post("/api/sessions/{guildId}/speech/order", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ Map body = ctx.bodyAsClass(Map.class);
+ String direction = body.get("direction");
+
+ if (direction == null || (!direction.equalsIgnoreCase("UP") && !direction.equalsIgnoreCase("DOWN"))) {
+ ctx.status(400);
+ ctx.json(Map.of("success", false, "error", "Invalid direction. Must be UP or DOWN."));
+ return;
+ }
+
+ try {
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "Session not found"));
+ return;
+ }
+
+ net.dv8tion.jda.api.entities.Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "Guild not found"));
+ return;
+ }
+
+ if (!dev.robothanzo.werewolf.commands.Speech.speechSessions.containsKey(guildId)) {
+ ctx.status(400);
+ ctx.json(Map.of("success", false, "error", "No active speech session"));
+ return;
+ }
+
+ // Find police
+ Session.Player police = null;
+ for (Session.Player p : session.fetchAlivePlayers().values()) {
+ if (p.isPolice()) {
+ police = p;
+ break;
+ }
+ }
+
+ if (police == null) {
+ // Start random if no police? Or just error?
+ // If no police, autostart should have handled it.
+ // But maybe we force random?
+ // Let's assume we map UP/DOWN to a random player if no police, or just error "No police to pivot around".
+ // Actually, let's pick the first player as pivot if no police, similar to random.
+ List players = new LinkedList<>(session.fetchAlivePlayers().values());
+ if (players.isEmpty()) {
+ ctx.status(400);
+ ctx.json(Map.of("success", false, "error", "No alive players"));
+ return;
+ }
+ police = players.get(0); // arbitrary pivot
+ }
+
+ dev.robothanzo.werewolf.commands.Speech.Order order = dev.robothanzo.werewolf.commands.Speech.Order.valueOf(direction.toUpperCase());
+ dev.robothanzo.werewolf.commands.Speech.changeOrder(guild, order, session.fetchAlivePlayers().values(), police);
+
+ // Notify Discord
+ net.dv8tion.jda.api.entities.channel.concrete.TextChannel channel = guild.getTextChannelById(session.getCourtTextChannelId());
+ if (channel != null) {
+ channel.sendMessage("法官已由網頁後台設定發言順序為: " + order).queue();
+ }
+
+ // Start the flow immediately
+ dev.robothanzo.werewolf.commands.Speech.speechSessions.get(guildId).next();
+
+ broadcastSessionUpdate(session);
+ ctx.json(Map.of("success", true, "message", "Speech order set to " + direction));
+ } catch (Exception e) {
+ log.error("Failed to set speech order", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Confirm/Start speech (Web equivalent of confirmOrder)
+ app.post("/api/sessions/{guildId}/speech/confirm", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ try {
+ if (!dev.robothanzo.werewolf.commands.Speech.speechSessions.containsKey(guildId)) {
+ ctx.status(400);
+ ctx.json(Map.of("success", false, "error", "No active speech session"));
+ return;
+ }
+
+ dev.robothanzo.werewolf.commands.Speech.SpeechSession speechSession = dev.robothanzo.werewolf.commands.Speech.speechSessions.get(guildId);
+ if (speechSession.getOrder().isEmpty()) {
+ ctx.status(400);
+ ctx.json(Map.of("success", false, "error", "Order not set"));
+ return;
+ }
+
+ speechSession.next();
+
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session != null) broadcastSessionUpdate(session);
+
+ ctx.json(Map.of("success", true, "message", "Speech confirmed and started"));
+ } catch (Exception e) {
+ log.error("Failed to confirm speech", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Skip current speaker
+ app.post("/api/sessions/{guildId}/speech/skip", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ try {
+ dev.robothanzo.werewolf.commands.Speech.skip(guildId);
+ ctx.json(Map.of("success", true, "message", "Skipped"));
+ } catch (Exception e) {
+ log.error("Failed to skip speech", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Interrupt speech
+ app.post("/api/sessions/{guildId}/speech/interrupt", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ try {
+ dev.robothanzo.werewolf.commands.Speech.interrupt(guildId);
+ ctx.json(Map.of("success", true, "message", "Interrupted"));
+ } catch (Exception e) {
+ log.error("Failed to interrupt speech", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Start manual timer
+ app.post("/api/sessions/{guildId}/speech/manual-start", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ Map body = ctx.bodyAsClass(Map.class);
+
+ Session session = Session.fetchCollection().find(eq("guildId", guildId)).first();
+ if (session == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "Session not found"));
+ return;
+ }
+
+ net.dv8tion.jda.api.entities.Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "Guild not found"));
+ return;
+ }
+
+ net.dv8tion.jda.api.entities.channel.concrete.TextChannel channel = guild.getTextChannelById(session.getCourtTextChannelId());
+ net.dv8tion.jda.api.entities.channel.middleman.AudioChannel voiceChannel = guild.getVoiceChannelById(session.getCourtVoiceChannelId());
+
+ if (channel == null) {
+ ctx.status(400);
+ ctx.json(Map.of("success", false, "error", "Court channel not found"));
+ return;
+ }
+
+ int duration = body.containsKey("duration") ? Integer.parseInt(body.get("duration").toString()) : 60;
+
+ try {
+ dev.robothanzo.werewolf.commands.Speech.startTimer(guild, channel, voiceChannel, duration);
+ ctx.json(Map.of("success", true, "message", "Timer started"));
+ } catch (Exception e) {
+ log.error("Failed to start timer", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Mute All
+ app.post("/api/sessions/{guildId}/speech/mute-all", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ net.dv8tion.jda.api.entities.Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "Guild not found"));
+ return;
+ }
+ try {
+ dev.robothanzo.werewolf.commands.Speech.muteAll(guild);
+ ctx.json(Map.of("success", true, "message", "Muted all"));
+ } catch (Exception e) {
+ log.error("Failed to mute all", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+ // Unmute All
+ app.post("/api/sessions/{guildId}/speech/unmute-all", ctx -> {
+ long guildId = Long.parseLong(ctx.pathParam("guildId"));
+ net.dv8tion.jda.api.entities.Guild guild = jda.getGuildById(guildId);
+ if (guild == null) {
+ ctx.status(404);
+ ctx.json(Map.of("success", false, "error", "Guild not found"));
+ return;
+ }
+ try {
+ dev.robothanzo.werewolf.commands.Speech.unmuteAll(guild);
+ ctx.json(Map.of("success", true, "message", "Unmuted all"));
+ } catch (Exception e) {
+ log.error("Failed to unmute all", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ }
+ });
+
+
+
+
+ // Error handling
+ app.exception(Exception.class, (e, ctx) -> {
+ log.error("API error", e);
+ ctx.status(500);
+ ctx.json(Map.of("success", false, "error", e.getMessage()));
+ });
+ }
+
+ public void broadcastSessionUpdate(Session session) {
+ try {
+ Map json = SessionAPI.toJSON(session, jda);
+ String jsonString = objectMapper.writeValueAsString(json);
+ wsClients.forEach(client -> {
+ try {
+ client.send(jsonString);
+ } catch (Exception e) {
+ log.error("Failed to send message to client", e);
+ }
+ });
+ } catch (Exception e) {
+ log.error("Failed to serialize session for broadcast", e);
+ }
+ }
+
+ public void broadcastEvent(String type, Map data) {
+ try {
+ Map message = new HashMap<>(data);
+ message.put("type", type);
+ String jsonString = objectMapper.writeValueAsString(message);
+ wsClients.forEach(client -> {
+ try {
+ if (client.session.isOpen()) {
+ client.send(jsonString);
+ }
+ } catch (Exception e) {
+ log.error("Failed to send event to client", e);
+ }
+ });
+ } catch (Exception e) {
+ log.error("Failed to serialize event for broadcast", e);
+ }
+ }
+
+ public void stop() {
+ if (app != null) {
+ app.stop();
+ log.info("Web server stopped");
+ }
+ }
+
+ /**
+ * Get user session from session cookie
+ */
+ private UserSession getUserSession(Context ctx) {
+ String sessionId = ctx.cookie("session_id");
+ if (sessionId == null) {
+ return null;
+ }
+
+ AuthSession doc = AuthSession.fetchCollection().find(eq("sessionId", sessionId)).first();
+ if (doc == null) return null;
+
+ return new UserSession(
+ doc.getSessionId(),
+ doc.getUserId(),
+ doc.getUsername(),
+ doc.getDiscriminator(),
+ doc.getAvatar(),
+ doc.getGuildId(),
+ doc.getRole(),
+ doc.getCreatedAt().getTime()
+ );
+ }
+
+ /**
+ * @param role JUDGE, SPECTATOR, BLOCKED, PENDING
+ */
+ public record UserSession(String sessionId, String userId, String username, String discriminator, String avatar,
+ long guildId, String role, long createdAt) {
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/utils/DiscordActionRunner.java b/src/main/java/dev/robothanzo/werewolf/utils/DiscordActionRunner.java
new file mode 100644
index 0000000..e6fb64b
--- /dev/null
+++ b/src/main/java/dev/robothanzo/werewolf/utils/DiscordActionRunner.java
@@ -0,0 +1,75 @@
+package dev.robothanzo.werewolf.utils;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import net.dv8tion.jda.api.requests.RestAction;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+
+public class DiscordActionRunner {
+
+ @Data
+ @AllArgsConstructor
+ public static class ActionTask {
+ public RestAction> action;
+ public String description;
+ }
+
+ /**
+ * Executes a list of Discord actions with progress tracking and status logging.
+ *
+ * @param tasks List of actions to perform
+ * @param statusLogger Consumer for status messages
+ * @param progressCallback Consumer for progress percentage
+ * @param startPercent The percentage to start from for this batch of actions
+ * @param endPercent The percentage to reach after all actions are done
+ * @param timeoutSeconds Maximum time to wait for all actions to complete
+ * @throws Exception if wait is interrupted or timed out
+ */
+ public static void runActions(List tasks, Consumer statusLogger,
+ Consumer progressCallback, int startPercent,
+ int endPercent, int timeoutSeconds) throws Exception {
+ int total = tasks.size();
+ if (total == 0) {
+ if (progressCallback != null) progressCallback.accept(endPercent);
+ return;
+ }
+
+ AtomicInteger completed = new AtomicInteger(0);
+ CompletableFuture allDone = new CompletableFuture<>();
+ int range = endPercent - startPercent;
+
+ for (ActionTask task : tasks) {
+ task.getAction().queue(success -> {
+ if (statusLogger != null) statusLogger.accept(" - [完成] " + task.getDescription());
+ handleTaskCompletion(completed, total, allDone, progressCallback, startPercent, range);
+ }, error -> {
+ if (statusLogger != null) statusLogger.accept(" - [失敗] " + task.getDescription() + ": " + error.getMessage());
+ handleTaskCompletion(completed, total, allDone, progressCallback, startPercent, range);
+ });
+ }
+
+ try {
+ allDone.get(timeoutSeconds, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ if (statusLogger != null) statusLogger.accept("警告: 部分 Discord 變更操作逾時或中斷 (" + e.getMessage() + ")");
+ throw e;
+ }
+ }
+
+ private static void handleTaskCompletion(AtomicInteger completed, int total, CompletableFuture allDone,
+ Consumer progressCallback, int startPercent, int range) {
+ int c = completed.incrementAndGet();
+ if (progressCallback != null) {
+ int currentProgress = startPercent + (int) ((c / (double) total) * range);
+ progressCallback.accept(currentProgress);
+ }
+ if (c == total) {
+ allDone.complete(null);
+ }
+ }
+}
diff --git a/src/main/java/dev/robothanzo/werewolf/utils/SetupHelper.java b/src/main/java/dev/robothanzo/werewolf/utils/SetupHelper.java
index 78ad5ff..304b28a 100644
--- a/src/main/java/dev/robothanzo/werewolf/utils/SetupHelper.java
+++ b/src/main/java/dev/robothanzo/werewolf/utils/SetupHelper.java
@@ -92,8 +92,9 @@ public static void setup(Guild guild, Server.PendingSetup config) {
TextChannel ch = guild.getTextChannelById(p.getChannelId());
if (ch != null) {
try {
- long allowBits = Permission.VIEW_CHANNEL.getRawValue() | Permission.MESSAGE_SEND.getRawValue();
- ch.upsertPermissionOverride(deadRole).setPermissions(allowBits, 0L).queue();
+ long allowBits = Permission.VIEW_CHANNEL.getRawValue();
+ long denyBits = Permission.MESSAGE_SEND.getRawValue();
+ ch.upsertPermissionOverride(deadRole).setPermissions(allowBits, denyBits).queue();
} catch (Exception ignore) {
}
}
@@ -199,12 +200,13 @@ private static void createPlayerRecursively(Guild guild, int total, int current,
return;
}
+ var id = Session.Player.ID_FORMAT.format(current);
guild.createRole()
- .setName("玩家" + current)
+ .setName("玩家" + id)
.setColor(MsgUtils.getRandomColor())
.setHoisted(true)
.queue(playerRole ->
- guild.createTextChannel("玩家" + current)
+ guild.createTextChannel("玩家" + id)
// Do not add spectator/dead overrides here (spectator is created later)
.addPermissionOverride(playerRole, List.of(Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND), List.of())
.addPermissionOverride(publicRole, List.of(), List.of(Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND, Permission.USE_APPLICATION_COMMANDS))
From 342a189a5934212973c18b8dd35222dee074fd31 Mon Sep 17 00:00:00 2001
From: RobotHanzo <36107150+RobotHanzo@users.noreply.github.com>
Date: Sun, 1 Feb 2026 20:48:04 +0800
Subject: [PATCH 4/5] stage 2 dashboard done
---
.idea/compiler.xml | 7 +
.vscode/settings.json | 3 +
build.gradle.kts | 45 +-
build_log.txt | 39 -
src/dashboard/README.md | 54 +-
src/dashboard/index.html | 36 +-
src/dashboard/postcss.config.js | 8 +-
src/dashboard/src/App.tsx | 1389 +++++++++--------
src/dashboard/src/components/AccessDenied.tsx | 21 +-
src/dashboard/src/components/AuthCallback.tsx | 16 +-
.../src/components/DeathConfirmModal.tsx | 37 +-
src/dashboard/src/components/GameHeader.tsx | 172 +-
src/dashboard/src/components/GameLog.tsx | 187 ++-
.../src/components/GameSettingsPage.tsx | 131 +-
src/dashboard/src/components/LoginScreen.tsx | 78 +-
src/dashboard/src/components/PlayerCard.tsx | 377 ++---
.../src/components/PlayerEditModal.tsx | 68 +-
.../src/components/PlayerSelectModal.tsx | 42 +-
.../src/components/ProgressOverlay.tsx | 113 +-
src/dashboard/src/components/RoleIcon.tsx | 32 +-
.../src/components/ServerSelector.tsx | 51 +-
.../src/components/SessionExpiredModal.tsx | 43 +
.../src/components/SettingsModal.tsx | 26 +-
src/dashboard/src/components/Sidebar.tsx | 295 ++--
src/dashboard/src/components/SpeakerCard.tsx | 63 +
.../src/components/SpectatorView.tsx | 52 +-
.../src/components/SpeechManager.tsx | 224 +--
src/dashboard/src/components/ThemeToggle.tsx | 14 +-
.../src/components/TimerControlModal.tsx | 41 +-
src/dashboard/src/components/VoteStatus.tsx | 135 ++
src/dashboard/src/contexts/AuthContext.tsx | 8 +-
src/dashboard/src/index.css | 174 +--
src/dashboard/src/lib/ThemeProvider.tsx | 6 +-
src/dashboard/src/lib/api.ts | 51 +-
src/dashboard/src/lib/i18n.ts | 2 +-
src/dashboard/src/lib/websocket.ts | 55 +-
src/dashboard/src/locales/zh-TW.json | 639 ++++----
src/dashboard/src/main.tsx | 10 +-
src/dashboard/src/mockData.ts | 47 +-
src/dashboard/src/types.ts | 136 +-
src/dashboard/tailwind.config.js | 18 +-
src/dashboard/tsconfig.json | 18 +-
src/dashboard/tsconfig.node.json | 4 +-
src/dashboard/vite.config.ts | 30 +-
.../werewolf/WerewolfApplication.java | 120 ++
.../robothanzo/werewolf/WerewolfHelper.java | 108 --
.../dev/robothanzo/werewolf/audio/Audio.java | 9 +-
.../robothanzo/werewolf/commands/Player.java | 316 ++--
.../robothanzo/werewolf/commands/Poll.java | 415 +----
.../robothanzo/werewolf/commands/Server.java | 120 +-
.../robothanzo/werewolf/commands/Speech.java | 531 +------
.../werewolf/config/SecurityConfig.java | 74 +
.../werewolf/config/SessionConfig.java | 9 +
.../werewolf/config/UserSessionFilter.java | 46 +
.../werewolf/config/WebSocketConfig.java | 25 +
.../werewolf/controller/AuthController.java | 149 ++
.../werewolf/controller/GameController.java | 211 +++
.../controller/SessionController.java | 52 +
.../werewolf/controller/SpeechController.java | 133 ++
.../database/documents/AuthSession.java | 34 +-
.../werewolf/database/documents/LogType.java | 14 +-
.../werewolf/database/documents/Session.java | 34 +-
.../werewolf/database/documents/UserRole.java | 31 +
.../werewolf/listeners/ButtonListener.java | 67 +-
.../werewolf/listeners/GuildJoinListener.java | 23 +-
.../listeners/MemberJoinListener.java | 4 +-
.../robothanzo/werewolf/model/Candidate.java | 64 +
.../werewolf/model/PoliceSession.java | 41 +
.../werewolf/model/SpeechOrder.java | 26 +
.../werewolf/model/SpeechSession.java | 31 +
.../security/GlobalWebSocketHandler.java | 83 +
.../werewolf/security/SessionRepository.java | 14 +
.../security/annotations/CanManageGuild.java | 14 +
.../security/annotations/CanViewGuild.java | 14 +
.../werewolf/server/SessionAPI.java | 1093 -------------
.../robothanzo/werewolf/server/WebServer.java | 1205 --------------
.../werewolf/service/DiscordService.java | 55 +
.../werewolf/service/GameActionService.java | 62 +
.../werewolf/service/GameSessionService.java | 126 ++
.../werewolf/service/PlayerService.java | 57 +
.../werewolf/service/PoliceService.java | 60 +
.../werewolf/service/RoleService.java | 47 +
.../werewolf/service/SpeechService.java | 91 ++
.../service/impl/DiscordServiceImpl.java | 116 ++
.../service/impl/GameActionServiceImpl.java | 282 ++++
.../service/impl/GameSessionServiceImpl.java | 433 +++++
.../service/impl/PlayerServiceImpl.java | 247 +++
.../service/impl/PoliceServiceImpl.java | 380 +++++
.../service/impl/RoleServiceImpl.java | 312 ++++
.../service/impl/SpeechServiceImpl.java | 547 +++++++
.../robothanzo/werewolf/utils/CmdUtils.java | 6 +-
.../werewolf/utils/DiscordActionRunner.java | 30 +-
.../werewolf/utils/IdentityUtils.java | 53 +
.../werewolf/utils/SetupHelper.java | 6 +-
src/main/resources/application.properties | 16 +
src/main/resources/logback.xml | 15 -
96 files changed, 7355 insertions(+), 5693 deletions(-)
create mode 100644 .vscode/settings.json
delete mode 100644 build_log.txt
create mode 100644 src/dashboard/src/components/SessionExpiredModal.tsx
create mode 100644 src/dashboard/src/components/SpeakerCard.tsx
create mode 100644 src/dashboard/src/components/VoteStatus.tsx
create mode 100644 src/main/java/dev/robothanzo/werewolf/WerewolfApplication.java
delete mode 100644 src/main/java/dev/robothanzo/werewolf/WerewolfHelper.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/config/SecurityConfig.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/config/SessionConfig.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/config/UserSessionFilter.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/config/WebSocketConfig.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/controller/AuthController.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/controller/GameController.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/controller/SessionController.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/controller/SpeechController.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/database/documents/UserRole.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/model/Candidate.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/model/PoliceSession.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/model/SpeechOrder.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/model/SpeechSession.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/security/GlobalWebSocketHandler.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/security/SessionRepository.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/security/annotations/CanManageGuild.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/security/annotations/CanViewGuild.java
delete mode 100644 src/main/java/dev/robothanzo/werewolf/server/SessionAPI.java
delete mode 100644 src/main/java/dev/robothanzo/werewolf/server/WebServer.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/DiscordService.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/GameActionService.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/GameSessionService.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/PlayerService.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/PoliceService.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/RoleService.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/SpeechService.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/impl/DiscordServiceImpl.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/impl/GameActionServiceImpl.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/impl/GameSessionServiceImpl.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/impl/PlayerServiceImpl.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/impl/PoliceServiceImpl.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/impl/RoleServiceImpl.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/service/impl/SpeechServiceImpl.java
create mode 100644 src/main/java/dev/robothanzo/werewolf/utils/IdentityUtils.java
create mode 100644 src/main/resources/application.properties
delete mode 100644 src/main/resources/logback.xml
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index 899ae05..1f7cb8b 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -12,4 +12,11 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..c5f3f6b
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "java.configuration.updateBuildConfiguration": "interactive"
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 2e50f38..4baaf3e 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,8 +1,7 @@
-import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
-
plugins {
java
- id("com.gradleup.shadow") version "9.3.1"
+ id("org.springframework.boot") version "4.0.2"
+ id("io.spring.dependency-management") version "1.1.7"
}
group = "dev.robothanzo.werewolf"
@@ -15,17 +14,17 @@ repositories {
}
dependencies {
+ // Spring Boot
+ implementation("org.springframework.boot:spring-boot-starter-web")
+ implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
+ implementation("org.springframework.boot:spring-boot-starter-websocket")
+ implementation("org.springframework.boot:spring-boot-starter-security")
+ implementation("org.mongodb:mongodb-spring-session:4.0.0-rc1")
+
+ // Discord
implementation("net.dv8tion:JDA:6.3.0")
implementation("club.minnced:discord-webhooks:0.8.4")
- implementation("org.mongodb:mongodb-driver-sync:5.6.2")
- implementation("ch.qos.logback:logback-classic:1.5.27")
implementation("com.github.RobotHanzo:JDAInteractions:v0.1.4")
-
- // Web Server Dependencies
- implementation("io.javalin:javalin:6.7.0")
- implementation("com.fasterxml.jackson.core:jackson-databind:2.21.0")
- implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.0")
- implementation("org.slf4j:slf4j-api:2.0.17")
implementation("com.github.Mokulu:discord-oauth2-api:1.0.4")
// JDA Audio supplements
@@ -37,25 +36,25 @@ dependencies {
compileOnly("org.projectlombok:lombok:1.18.42")
annotationProcessor("org.projectlombok:lombok:1.18.42")
+ testImplementation("org.springframework.boot:spring-boot-starter-test")
}
-tasks {
- compileJava {
- options.encoding = Charsets.UTF_8.name()
- options.release.set(25)
- }
+configurations.all {
+ exclude(group = "org.slf4j", module = "slf4j-reload4j")
+}
- jar {
- manifest {
- attributes["Main-Class"] = "dev.robothanzo.werewolf.WerewolfHelper"
+tasks {
+ java {
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(25))
}
}
- named("shadowJar") {
- archiveClassifier.set("")
+ compileJava {
+ options.encoding = Charsets.UTF_8.name()
}
- build {
- dependsOn(shadowJar)
+ bootJar {
+ mainClass.set("dev.robothanzo.werewolf.WerewolfApplication")
}
}
\ No newline at end of file
diff --git a/build_log.txt b/build_log.txt
deleted file mode 100644
index 2622cf3..0000000
--- a/build_log.txt
+++ /dev/null
@@ -1,39 +0,0 @@
-Initialized native services in: C:\Users\Nathan Lee\.gradle\native
-Initialized jansi services in: C:\Users\Nathan Lee\.gradle\native
-The client will now receive all logging from the daemon (pid: 45728). The daemon log file: C:\Users\Nathan Lee\.gradle\daemon\9.3.1\daemon-45728.out.log
-Starting 46th build in daemon [uptime: 5 hrs 12 mins 20.154 secs, performance: 99%, GC rate: 0.00/s, heap usage: 0% of 512 MiB, non-heap usage: 44% of 384 MiB]
-Using 20 worker leases.
-Operational build model parameters: {requiresToolingModels=false, parallelProjectExecution=false, configureOnDemand=false, configurationCache=false, configurationCacheParallelStore=false, configurationCacheParallelLoad=true, isolatedProjects=false, parallelProjectConfiguration=false, intermediateModelCache=false, parallelToolingApiActions=false, invalidateCoupledProjects=false, modelAsProjectDependency=false, resilientModelBuilding=false}
-Now considering [C:\Users\Nathan Lee\Coding Projects\Java Projects\WerewolfHelper] as hierarchies to watch
-Watching the file system is configured to be enabled if available
-File system watching is active
-Starting Build
-Settings evaluated using settings file 'C:\Users\Nathan Lee\Coding Projects\Java Projects\WerewolfHelper\settings.gradle.kts'.
-Projects loaded. Root project using build file 'C:\Users\Nathan Lee\Coding Projects\Java Projects\WerewolfHelper\build.gradle.kts'.
-Included projects: [root project 'WerewolfHelper']
-
-> Configure project :
-Evaluating root project 'WerewolfHelper' using build file 'C:\Users\Nathan Lee\Coding Projects\Java Projects\WerewolfHelper\build.gradle.kts'.
-Resolved plugin [id: 'org.gradle.java']
-Resolved plugin [id: 'com.gradleup.shadow', version: '9.3.1']
-Skipping Develocity integration for Shadow plugin.
-Setting org.gradle.jvm.version attribute for shadowRuntimeElements configuration.
-Setting target JVM version to 21 for shadowRuntimeElements configuration.
-Adding shadowRuntimeElements variant to Java component.
-All projects evaluated.
-Task name matched 'compileJava'
-Selected primary task 'compileJava' from project :
-Tasks to be executed: [task ':compileJava']
-Tasks that were excluded: []
-Resolve mutations for :compileJava (Thread[#7164,Execution worker,5,main]) started.
-:compileJava (Thread[#7178,Execution worker Thread 15,5,main]) started.
-
-> Task :compileJava UP-TO-DATE
-Caching disabled for task ':compileJava' because:
- Build cache is disabled
-Skipping task ':compileJava' as it is up-to-date.
-
-BUILD SUCCESSFUL in 1s
-1 actionable task: 1 up-to-date
-Watched directory hierarchies: [C:\Users\Nathan Lee\Coding Projects\Java Projects\WerewolfHelper]
-Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.3.1/userguide/configuration_cache_enabling.html
diff --git a/src/dashboard/README.md b/src/dashboard/README.md
index 1e88c35..dd6c990 100644
--- a/src/dashboard/README.md
+++ b/src/dashboard/README.md
@@ -1,7 +1,9 @@
# Werewolf Helper Dashboard
-This is the admin dashboard frontend for the Werewolf Discord Bot. It allows admins to view the game state in real-time and execute commands.
-*Made by vibe-coding using Google Antigravity, I take no credit for the design, and no responsibility for any issues that may arise.*
+This is the admin dashboard frontend for the Werewolf Discord Bot. It allows admins to view the game state in real-time
+and execute commands.
+*Made by vibe-coding using Google Antigravity, I take no credit for the design, and no responsibility for any issues
+that may arise.*
## Prerequisites
@@ -10,12 +12,12 @@ This is the admin dashboard frontend for the Werewolf Discord Bot. It allows adm
## Installation
-1. Clone the repository (or download the source).
-2. Install dependencies:
+1. Clone the repository (or download the source).
+2. Install dependencies:
- ```bash
- yarn install
- ```
+ ```bash
+ yarn install
+ ```
## Development
@@ -35,7 +37,8 @@ To build the application for production:
yarn build
```
-The output will be in the `dist/` directory. You can serve this static directory using any web server (Nginx, Apache, Vercel, Netlify, etc.).
+The output will be in the `dist/` directory. You can serve this static directory using any web server (Nginx, Apache,
+Vercel, Netlify, etc.).
### Preview Production Build
@@ -50,30 +53,31 @@ yarn preview
The dashboard uses Discord OAuth2 for authentication. To set this up:
1. **Create a Discord Application**:
- - Go to the [Discord Developer Portal](https://discord.com/developers/applications)
- - Click **New Application** and give it a name
- - Navigate to the **OAuth2** section
+ - Go to the [Discord Developer Portal](https://discord.com/developers/applications)
+ - Click **New Application** and give it a name
+ - Navigate to the **OAuth2** section
2. **Configure Redirect URIs**:
- - Add your redirect URI (e.g., `http://localhost:5173/auth/callback` for local development)
- - For production, use your deployed dashboard URL (e.g., `https://yourdomain.com/auth/callback`)
+ - Add your redirect URI (e.g., `http://localhost:5173/auth/callback` for local development)
+ - For production, use your deployed dashboard URL (e.g., `https://yourdomain.com/auth/callback`)
3. **Get Your Credentials**:
- - Copy your **Client ID** from the General Information page
- - Generate a **Client Secret** from the OAuth2 page
+ - Copy your **Client ID** from the General Information page
+ - Generate a **Client Secret** from the OAuth2 page
4. **Set Environment Variables**:
- - The backend requires the following environment variables:
- ```bash
- DISCORD_CLIENT_ID=your_client_id_here
- DISCORD_CLIENT_SECRET=your_client_secret_here
- DISCORD_REDIRECT_URI=http://localhost:5173/auth/callback
- DASHBOARD_URL=http://localhost:5173
- ```
-
+ - The backend requires the following environment variables:
+ ```bash
+ DISCORD_CLIENT_ID=your_client_id_here
+ DISCORD_CLIENT_SECRET=your_client_secret_here
+ DISCORD_REDIRECT_URI=http://localhost:5173/auth/callback
+ DASHBOARD_URL=http://localhost:5173
+ ```
+
5. **Bot Permissions**:
- - The OAuth2 application needs the following scopes: `identify`, `guilds`, `guilds.members.read`
+ - The OAuth2 application needs the following scopes: `identify`, `guilds`, `guilds.members.read`
## Integration
-Refer to the "Integration Guide" within the dashboard application for details on how to connect this frontend to your Java Discord Bot backend.
+Refer to the "Integration Guide" within the dashboard application for details on how to connect this frontend to your
+Java Discord Bot backend.
diff --git a/src/dashboard/index.html b/src/dashboard/index.html
index 6eebae0..6f0c158 100644
--- a/src/dashboard/index.html
+++ b/src/dashboard/index.html
@@ -1,25 +1,25 @@
-
-
- 狼人殺助手 - 管理員儀表板
-
-
+
+
+ 狼人殺助手 - 管理員儀表板
+
+
-
-
+
+
\ No newline at end of file
diff --git a/src/dashboard/postcss.config.js b/src/dashboard/postcss.config.js
index e99ebc2..5aa5df0 100644
--- a/src/dashboard/postcss.config.js
+++ b/src/dashboard/postcss.config.js
@@ -1,6 +1,6 @@
export default {
- plugins: {
- tailwindcss: {},
- autoprefixer: {},
- },
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
}
\ No newline at end of file
diff --git a/src/dashboard/src/App.tsx b/src/dashboard/src/App.tsx
index 78471e0..e0d879e 100644
--- a/src/dashboard/src/App.tsx
+++ b/src/dashboard/src/App.tsx
@@ -1,683 +1,818 @@
-import { useState, useEffect, useRef } from 'react';
-import { Routes, Route, useParams, useNavigate } from 'react-router-dom';
-import { Users } from 'lucide-react';
+import {useEffect, useRef, useState} from 'react';
+import {Route, Routes, useNavigate, useParams} from 'react-router-dom';
+import {MessageSquare, Users, X} from 'lucide-react';
import './index.css';
-import { GameState, GamePhase } from './types';
-import { INITIAL_PLAYERS } from './mockData';
-import { LoginScreen } from './components/LoginScreen';
-import { ServerSelector } from './components/ServerSelector';
-import { Sidebar } from './components/Sidebar';
-import { GameHeader } from './components/GameHeader';
-import { PlayerCard } from './components/PlayerCard';
-import { GameLog } from './components/GameLog';
-import { SettingsModal } from './components/SettingsModal';
-import { PlayerEditModal } from './components/PlayerEditModal';
-import { DeathConfirmModal } from './components/DeathConfirmModal';
-import { SpectatorView } from './components/SpectatorView';
-import { SpeechManager } from './components/SpeechManager';
-import { GameSettingsPage } from './components/GameSettingsPage';
-import { AuthCallback } from './components/AuthCallback';
-import { useTranslation } from './lib/i18n';
-import { useWebSocket } from './lib/websocket';
-import { AccessDenied } from './components/AccessDenied';
-import { ProgressOverlay } from './components/ProgressOverlay';
-import { TimerControlModal } from './components/TimerControlModal';
-import { PlayerSelectModal } from './components/PlayerSelectModal';
-import { api } from './lib/api';
-import { useAuth } from './contexts/AuthContext';
+import {GamePhase, GameState} from './types';
+import {INITIAL_PLAYERS} from './mockData';
+import {LoginScreen} from './components/LoginScreen';
+import {ServerSelector} from './components/ServerSelector';
+import {Sidebar} from './components/Sidebar';
+import {GameHeader} from './components/GameHeader';
+import {PlayerCard} from './components/PlayerCard';
+import {GameLog} from './components/GameLog';
+import {SettingsModal} from './components/SettingsModal';
+import {PlayerEditModal} from './components/PlayerEditModal';
+import {DeathConfirmModal} from './components/DeathConfirmModal';
+import {SpectatorView} from './components/SpectatorView';
+import {SpeechManager} from './components/SpeechManager';
+import {GameSettingsPage} from './components/GameSettingsPage';
+import {AuthCallback} from './components/AuthCallback';
+import {useTranslation} from './lib/i18n';
+import {useWebSocket} from './lib/websocket';
+import {AccessDenied} from './components/AccessDenied';
+import {ProgressOverlay} from './components/ProgressOverlay';
+import {VoteStatus} from './components/VoteStatus';
+import {TimerControlModal} from './components/TimerControlModal';
+import {PlayerSelectModal} from './components/PlayerSelectModal';
+import {SessionExpiredModal} from './components/SessionExpiredModal';
+import {api} from './lib/api';
+import {useAuth} from './contexts/AuthContext';
const Dashboard = () => {
- const { guildId } = useParams<{ guildId: string }>();
- const navigate = useNavigate();
- const { t } = useTranslation();
- const { user, loading, logout, checkAuth } = useAuth();
- const [showSettings, setShowSettings] = useState(false);
- const [editingPlayerId, setEditingPlayerId] = useState(null);
- const [deathConfirmPlayerId, setDeathConfirmPlayerId] = useState(null);
-
- // New Modal States
- const [showTimerModal, setShowTimerModal] = useState(false);
- const [playerSelectModal, setPlayerSelectModal] = useState<{
- visible: boolean;
- type: 'ASSIGN_JUDGE' | 'DEMOTE_JUDGE' | 'FORCE_POLICE' | null;
- customPlayers?: any[]; // Allow partial player objects or mapped ones
- }>({ visible: false, type: null });
-
- const [gameState, setGameState] = useState({
- phase: 'LOBBY',
- dayCount: 0,
- timerSeconds: 0,
- players: INITIAL_PLAYERS,
- logs: [],
- });
-
- // Progress Overlay State
- const [overlayVisible, setOverlayVisible] = useState(false);
- const [overlayTitle, setOverlayTitle] = useState('');
- const [overlayLogs, setOverlayLogs] = useState([]);
- const [overlayStatus, setOverlayStatus] = useState<'processing' | 'success' | 'error'>('processing');
- const [overlayError, setOverlayError] = useState(undefined);
- const [overlayProgress, setOverlayProgress] = useState(undefined);
-
- 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;
- }
+ const {guildId} = useParams<{ guildId: string }>();
+ const navigate = useNavigate();
+ const {t} = useTranslation();
+ const {user, loading, logout, checkAuth} = useAuth();
+ const [showSettings, setShowSettings] = useState(false);
+ const [editingPlayerId, setEditingPlayerId] = useState(null);
+ const [deathConfirmPlayerId, setDeathConfirmPlayerId] = useState(null);
+ const [showSessionExpired, setShowSessionExpired] = useState(false);
+
+ // New Modal States
+ const [showTimerModal, setShowTimerModal] = useState(false);
+ const [playerSelectModal, setPlayerSelectModal] = useState<{
+ visible: boolean;
+ type: 'ASSIGN_JUDGE' | 'DEMOTE_JUDGE' | 'FORCE_POLICE' | null;
+ customPlayers?: any[]; // Allow partial player objects or mapped ones
+ }>({visible: false, type: null});
+ const [showLogs, setShowLogs] = useState(false);
+ const [lastSeenLogCount, setLastSeenLogCount] = useState(0);
+ const [isSpectatorSimulation, setIsSpectatorSimulation] = useState(false);
+
+ const [gameState, setGameState] = useState({
+ phase: 'LOBBY',
+ dayCount: 0,
+ timerSeconds: 0,
+ players: INITIAL_PLAYERS,
+ logs: [],
+ });
+
+ // Progress Overlay State
+ const [overlayVisible, setOverlayVisible] = useState(false);
+ const [overlayTitle, setOverlayTitle] = useState('');
+ const [overlayLogs, setOverlayLogs] = useState([]);
+ const [overlayStatus, setOverlayStatus] = useState<'processing' | 'success' | 'error'>('processing');
+ const [overlayError, setOverlayError] = useState(undefined);
+ const [overlayProgress, setOverlayProgress] = useState(undefined);
+
+ const isGuildReady = user && user.guildId && user.guildId.toString() === guildId;
+
+ const isSelectingGuild = useRef(false);
+
+ // Check authentication and authorization
+ useEffect(() => {
+ if (loading) return;
+
+ if (!user) {
+ navigate('/login');
+ 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();
+ // 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();
}
- }
- } catch (error) {
- console.error('Failed to switch guild:', error);
- alert('Failed to switch server. Please try again.');
- } finally {
- isSelectingGuild.current = false;
+ return;
}
- };
- 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]);
-
- // Helper to map session data to GameState players
- const mapSessionToPlayers = (sessionData: any) => {
- 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, // Not explicitly exposed in API JSON, assumed handled by statuses or hidden
- isPoisoned: false,
- isSilenced: false,
- isDuplicated: player.duplicated,
- isJudge: player.isJudge || false,
- rolePositionLocked: player.rolePositionLocked,
- statuses: [
- ...(player.police ? ['sheriff'] : []),
- ...(player.jinBaoBao ? ['jinBaoBao'] : []),
- ] as Array<'sheriff' | 'jinBaoBao' | 'protected' | 'poisoned' | 'silenced'>,
- }));
- };
-
- // WebSocket connection for real-time updates
- const { isConnected } = useWebSocket((data) => {
- // Check for progress events
- if (data.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) {
- if (data.message) {
- setOverlayLogs(prev => [...prev, data.message]);
+ // 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;
}
- if (data.percent !== undefined) {
- setOverlayProgress(data.percent);
+
+ // 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;
}
- return;
- }
- }
- // Check if the update is for the current guild
- if (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,
- logs: data.logs || prev.logs,
- }));
- }
- });
-
- useEffect(() => {
- const interval = setInterval(() => {
- setGameState(prev => {
- let newTimer = prev.timerSeconds;
- if (prev.phase !== 'LOBBY' && prev.phase !== 'GAME_OVER' && prev.timerSeconds > 0) {
- newTimer -= 1;
+ // 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`);
+ }
}
- return { ...prev, timerSeconds: newTimer };
- });
- }, 1000);
- return () => clearInterval(interval);
- }, []);
+ }, [user, loading, guildId, navigate, checkAuth]);
- // Load game state when component mounts or guild ID changes
- useEffect(() => {
- if (!guildId) return;
+ // Security check: Lock dashboard if player has assigned roles and is not a privileged user
+ useEffect(() => {
+ if (!user || loading) return;
- const loadGameState = async () => {
- try {
- const sessionData: any = await api.getSession(guildId);
- console.log('Session data:', sessionData);
+ // Privileged roles are exempt
+ if (user.role === 'JUDGE' || user.role === 'SPECTATOR') return;
- const players = mapSessionToPlayers(sessionData);
+ // Check if current user has any in-game roles
+ // We use loose comparison for ID just in case, but strict should work if types align
+ const currentPlayer = gameState.players.find(p => p.userId === user.userId);
- setGameState(prev => ({
- ...prev,
- players: players,
- doubleIdentities: sessionData.doubleIdentities,
- availableRoles: sessionData.roles || [],
- speech: sessionData.speech,
- police: sessionData.police,
- logs: sessionData.logs || [],
+ // If player exists and has roles assigned, lock them out
+ if (currentPlayer && currentPlayer.roles && currentPlayer.roles.length > 0) {
+ navigate('/access-denied');
+ }
+ }, [user, loading, gameState.players, navigate]);
+
+ // Helper to map session data to GameState players
+ const mapSessionToPlayers = (sessionData: any) => {
+ 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, // Not explicitly exposed in API JSON, assumed handled by statuses or hidden
+ isPoisoned: false,
+ isSilenced: false,
+ isDuplicated: player.duplicated,
+ isJudge: player.isJudge || false,
+ rolePositionLocked: player.rolePositionLocked,
+ statuses: [
+ ...(player.police ? ['sheriff'] : []),
+ ...(player.jinBaoBao ? ['jinBaoBao'] : []),
+ ] as Array<'sheriff' | 'jinBaoBao' | 'protected' | 'poisoned' | 'silenced'>,
}));
- } catch (error) {
- console.error('Failed to load session data:', error);
- }
};
- loadGameState();
- }, [guildId]); // Removed 't' and addLog calls to prevent infinite loop
+ // WebSocket connection for real-time updates
+ 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
+ });
- const handleAction = async (playerId: string, actionType: string) => {
- if (!guildId) return;
- const player = gameState.players.find(p => p.id === playerId);
- if (!player) return;
+ if (data.guildId?.toString() === guildId) {
+ setOverlayVisible(true);
+
+ const isError = data.message && (data.message.includes('錯誤') || data.message.includes('Error') || data.message.includes('Failed'));
+
+ if (data.percent === 0) {
+ setOverlayLogs(data.message ? [data.message] : []);
+ setOverlayStatus('processing');
+ setOverlayError(undefined);
+ setOverlayTitle(t('progressOverlay.processing'));
+ } else if (data.message) {
+ setOverlayLogs(prev => [...prev, data.message]);
+ }
+
+ if (isError) {
+ setOverlayStatus('error');
+ setOverlayError(data.message || 'Unknown Error');
+ }
+
+ if (data.percent !== undefined) {
+ setOverlayProgress(data.percent);
+ if (data.percent >= 100 && !isError) {
+ setOverlayStatus('success');
+ }
+ }
+ return;
+ }
+ }
- if (actionType === 'role') {
- setEditingPlayerId(playerId);
- 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,
+ logs: data.logs || prev.logs,
+ }));
+ }
+ }, guildId, () => setShowSessionExpired(true));
+
+ 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 when component mounts or guild ID changes
+ useEffect(() => {
+ if (!guildId) return;
+
+ // Skip fetch if user is not loaded or not on the correct guild yet
+ // This prevents "Please switch to this server first" errors while the
+ // other useEffect is switching the user's guild.
+ 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,
+ logs: sessionData.logs || [],
+ }));
+ } catch (error) {
+ console.error('Failed to load session data:', error);
+ }
+ };
- const playerName = player.name;
- addLog(t('gameLog.adminCommand', { action: actionType, player: playerName }));
+ loadGameState();
+ }, [guildId, user]); // Added user dependency to retry after guild switch
- try {
- if (actionType === 'kill') {
- if (player.userId) {
- setDeathConfirmPlayerId(playerId);
- } else {
- console.warn('Cannot kill unassigned player via API');
+ 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;
}
- } else if (actionType === 'revive') {
- if (player.userId) {
- await api.revivePlayer(guildId, player.userId);
+
+ 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 (not seen in API yet, skipping)
+ } 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}));
}
- } else if (actionType.startsWith('revive_role:')) {
- const role = actionType.split(':')[1];
- if (player.userId) {
- await api.reviveRole(guildId, player.userId, role);
+ };
+
+ 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'
+ }]
+ }));
+ } 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 () => {
+ setOverlayVisible(true);
+ setOverlayTitle(t('progressOverlay.resetTitle'));
+ setOverlayStatus('processing');
+ setOverlayLogs([t('overlayMessages.resetting')]);
+ setOverlayError(undefined);
+ setOverlayProgress(0);
+
+ try {
+ if (guildId) {
+ await api.resetSession(guildId);
+ } else {
+ throw new Error("Missing Guild ID");
+ }
+
+ setOverlayStatus('success');
+ setOverlayLogs(prev => [...prev, t('overlayMessages.resetSuccess')]);
+ } catch (error: any) {
+ console.error("Reset failed", error);
+ setOverlayStatus('error');
+ setOverlayLogs(prev => [...prev, `${t('errors.error')}: ${error.message || t('errors.unknownError')}`]);
+ setOverlayError(error.message || t('errors.resetFailed'));
+ }
+ };
+ performReset();
+ } else if (action === 'random_assign') {
+ addLog(t('gameLog.randomizeRoles'));
+
+ const performRandomAssign = async () => {
+ setOverlayVisible(true);
+ setOverlayTitle(t('messages.randomAssignRoles'));
+ setOverlayStatus('processing');
+ setOverlayLogs([t('overlayMessages.requestingAssign')]);
+ setOverlayError(undefined);
+ setOverlayProgress(0);
+
+ try {
+ if (guildId) {
+ await api.assignRoles(guildId);
+ } else {
+ throw new Error("Missing Guild ID");
+ }
+
+ setOverlayLogs(prev => [...prev]);
+
+ setOverlayStatus('success');
+ setOverlayLogs(prev => [...prev, t('overlayMessages.assignSuccess')]);
+ } catch (error: any) {
+ console.error("Assign failed", error);
+ setOverlayStatus('error');
+ setOverlayLogs(prev => [...prev, `${t('errors.error')}: ${error.message || t('errors.unknownError')}`]);
+ setOverlayError(error.message || t('errors.assignFailed'));
+ }
+ };
+ performRandomAssign();
+ } else if (action === 'start_game') {
+ const performStart = async () => {
+ try {
+ if (guildId) {
+ await api.startGame(guildId);
+ }
+ } catch (error: any) {
+ console.error("Start game failed", error);
+ }
+ };
+ performStart();
+ } 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 === 'manage_judges') {
+ // PROPOSE: This should ideally be two buttons? Or one "Judge Manager"?
+ // User asked for: /player judge (assign) AND /player demote (remove)
+ // I will implement two separate actions in GameLog, handled here.
+ } 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, // Use effective 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'});
}
- } else if (actionType === 'toggle-jin') {
- // Toggle Jin Bao Bao logic (not seen in API yet, skipping)
- } else if (actionType === 'sheriff') {
- if (player.userId) {
- await api.setPolice(guildId, player.userId);
+ };
+
+ const handleTimerStart = (seconds: number) => {
+ if (guildId) {
+ api.manualStartTimer(guildId, seconds);
+ addLog(t('gameLog.manualCommand', {cmd: `Timer ${seconds}s`}));
}
- } else if (actionType === 'switch_role_order') {
- if (player.userId) {
- await api.switchRoleOrder(guildId, player.userId);
+ };
+
+ 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'); // Default demote back to spectator? Or PENDING? Safe to say Spectator or allow re-login. Let's start with 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}`}));
}
- }
+ };
- } 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' }]
- }));
- } 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 () => {
- setOverlayVisible(true);
- setOverlayTitle(t('progressOverlay.resetTitle'));
- setOverlayStatus('processing');
- setOverlayLogs([t('overlayMessages.resetting')]);
- setOverlayError(undefined);
- setOverlayProgress(0);
+ 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)
+ }));
+ };
- try {
- if (guildId) {
- await api.resetSession(guildId);
- } else {
- throw new Error("Missing Guild ID");
- }
-
- setOverlayStatus('success');
- setOverlayLogs(prev => [...prev, t('overlayMessages.resetSuccess')]);
- } catch (error: any) {
- console.error("Reset failed", error);
- setOverlayStatus('error');
- setOverlayLogs(prev => [...prev, `${t('errors.error')}: ${error.message || t('errors.unknownError')}`]);
- setOverlayError(error.message || t('errors.resetFailed'));
+ const toggleSpectatorSimulation = () => {
+ const newMode = !isSpectatorSimulation;
+ setIsSpectatorSimulation(newMode);
+ if (newMode) {
+ navigate(`/server/${guildId}/spectator`);
+ } else {
+ navigate(`/server/${guildId}`);
}
- };
- performReset();
- } else if (action === 'random_assign') {
- addLog(t('gameLog.randomizeRoles'));
-
- const performRandomAssign = async () => {
- setOverlayVisible(true);
- setOverlayTitle(t('messages.randomAssignRoles'));
- setOverlayStatus('processing');
- setOverlayLogs([t('overlayMessages.requestingAssign')]);
- setOverlayError(undefined);
- setOverlayProgress(0);
+ };
- try {
- if (guildId) {
- await api.assignRoles(guildId);
- } else {
- throw new Error("Missing Guild ID");
- }
-
- setOverlayLogs(prev => [...prev]);
-
- setOverlayStatus('success');
- setOverlayLogs(prev => [...prev, t('overlayMessages.assignSuccess')]);
- } catch (error: any) {
- console.error("Assign failed", error);
- setOverlayStatus('error');
- setOverlayLogs(prev => [...prev, `${t('errors.error')}: ${error.message || t('errors.unknownError')}`]);
- setOverlayError(error.message || t('errors.assignFailed'));
+ const toggleLogs = () => {
+ const newShowLogs = !showLogs;
+ setShowLogs(newShowLogs);
+ if (newShowLogs) {
+ setLastSeenLogCount(gameState.logs.length);
}
- };
- performRandomAssign();
- } else if (action === 'start_game') {
- const performStart = async () => {
- try {
- if (guildId) {
- await api.startGame(guildId);
- }
- } catch (error: any) {
- console.error("Start game failed", error);
+ };
+
+ // Also update count if logs change while open
+ useEffect(() => {
+ if (showLogs) {
+ setLastSeenLogCount(gameState.logs.length);
}
- };
- performStart();
- } 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 === 'manage_judges') {
- // PROPOSE: This should ideally be two buttons? Or one "Judge Manager"?
- // User asked for: /player judge (assign) AND /player demote (remove)
- // I will implement two separate actions in GameLog, handled here.
- } 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, // Use effective 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' });
- }
- };
+ }, [gameState.logs.length, showLogs]);
- 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'); // Default demote back to spectator? Or PENDING? Safe to say Spectator or allow re-login. Let's start with 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}` }));
- }
- };
-
- 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 [isSpectatorSimulation, setIsSpectatorSimulation] = useState(false);
-
- const toggleSpectatorSimulation = () => {
- const newMode = !isSpectatorSimulation;
- setIsSpectatorSimulation(newMode);
- if (newMode) {
- navigate(`/server/${guildId}/spectator`);
- } else {
- navigate(`/server/${guildId}`);
- }
- };
-
- const editingPlayer = gameState.players.find(p => p.id === editingPlayerId);
-
- 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}
- />
-
-
-
- {/* Main Content Area */}
-
-
-
-
-
-
-
- {t('players.title')} ({gameState.players.filter(p => p.isAlive).length} {t('players.alive')})
-
-
-
- {gameState.players.map(player => (
-
- ))}
-
- >
- } />
-
- } />
- p.id === editingPlayerId);
+
+ 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}
+ />
+
+
- } />
-
- } />
-
-
- {/* Mobile Game Log */}
-
-
-
-
-
-
- {/* Desktop Right Sidebar Game Log */}
-
-
-
+
+ {/* Main Content Area */}
+
+
+ {isGuildReady ? (
+ <>
+
+
+
+
+
+ {t('players.title')} ({gameState.players.filter(p => p.isAlive).length} {t('players.alive')})
+
+
+
+ {gameState.players.map(player => (
+
+ ))}
+
+ >
+ }/>
+
+ }/>
+
+ }/>
+
+ }/>
+
+
+ {/* Game log is now floating */}
+ >
+ ) : (
+
+
+
+
{t('serverSelector.switching')}
+
+
+ )}
+
+
+
+
+ {/* Floating Game Log */}
+ {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)}
+ />
+ )}
+
+ setOverlayVisible(false)}
+ />
+
+
+ p.isAlive).length}
+ endTime={undefined} // Expel usually has no fixed timer or handled differently
+ players={gameState.players}
+ title={t('vote.expelVote')}
+ />
+
+
+ {showTimerModal && (
+ setShowTimerModal(false)}
+ onStart={handleTimerStart}
+ />
+ )}
+
+ {
+ setShowSessionExpired(false);
+ window.location.href = '/login';
+ }}
+ />
+
+ {playerSelectModal.visible && (
+ setPlayerSelectModal({
+ ...playerSelectModal,
+ visible: false,
+ customPlayers: undefined
+ })}
+ onSelect={handlePlayerSelect}
+ filter={(p) => {
+ // Filtering logic based on user roles and requirements
+ if (!p.userId) return false; // Must be a real user
+
+ if (playerSelectModal.type === 'ASSIGN_JUDGE') {
+ return !p.isJudge;
+ }
+ if (playerSelectModal.type === 'DEMOTE_JUDGE') {
+ return !!p.isJudge;
+ }
+ if (playerSelectModal.type === 'FORCE_POLICE') {
+ // Should only show players who are alive? or just all players?
+ // Usually force police is for alive players.
+ return p.isAlive;
+ }
+ return true;
+ }}
+ />
+ )}
+
- {showSettings &&
setShowSettings(false)} />}
- {editingPlayerId && editingPlayer && guildId && (
- setEditingPlayerId(null)}
- doubleIdentities={gameState.doubleIdentities}
- availableRoles={gameState.availableRoles || []}
- />
- )}
- {deathConfirmPlayerId && guildId && (
- p.id === deathConfirmPlayerId)!}
- guildId={guildId}
- onClose={() => setDeathConfirmPlayerId(null)}
- />
- )}
-
- setOverlayVisible(false)}
- />
-
- {showTimerModal && (
- setShowTimerModal(false)}
- onStart={handleTimerStart}
- />
- )}
-
- {playerSelectModal.visible && (
- setPlayerSelectModal({ ...playerSelectModal, visible: false, customPlayers: undefined })}
- onSelect={handlePlayerSelect}
- filter={(p) => {
- // Filtering logic based on user roles and requirements
- if (!p.userId) return false; // Must be a real user
-
- if (playerSelectModal.type === 'ASSIGN_JUDGE') {
- return !p.isJudge;
- }
- if (playerSelectModal.type === 'DEMOTE_JUDGE') {
- return !!p.isJudge;
- }
- if (playerSelectModal.type === 'FORCE_POLICE') {
- // Should only show players who are alive? or just all players?
- // Usually force police is for alive players.
- return p.isAlive;
- }
- return true;
- }}
- />
- )}
-
-
- );
+ );
};
const LoginPage = () => {
- const handleLogin = () => {
- // Redirect to OAuth login (no guild_id yet)
- window.location.href = '/api/auth/login';
- };
- return ;
+ const handleLogin = () => {
+ // Redirect to OAuth login (no guild_id yet)
+ window.location.href = '/api/auth/login';
+ };
+ return ;
};
const ServerSelectionPage = () => {
- const navigate = useNavigate();
- const { user, loading } = useAuth();
+ const navigate = useNavigate();
+ const {user, loading} = useAuth();
- // Redirect to login if not authenticated
- useEffect(() => {
- if (!loading && !user) {
- navigate('/login');
- }
- }, [user, loading, navigate]);
+ // Redirect to login if not authenticated
+ useEffect(() => {
+ if (!loading && !user) {
+ navigate('/login');
+ }
+ }, [user, loading, navigate]);
- // Show loading while checking auth
- if (loading) {
- return (
-
- );
- }
+ // Show loading while checking auth
+ if (loading) {
+ return (
+
+ );
+ }
- if (!user) {
- return null;
- }
+ if (!user) {
+ return null;
+ }
- const handleSelectServer = (guildId: string) => {
- navigate(`/server/${guildId}`);
- };
- return navigate('/login')} />;
+ const handleSelectServer = (guildId: string) => {
+ navigate(`/server/${guildId}`);
+ };
+ return navigate('/login')}/>;
};
const App = () => {
- return (
-
- } />
- } />
- } />
- } />
- } />
-
- );
+ return (
+
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+
+ );
};
export default App;
\ No newline at end of file
diff --git a/src/dashboard/src/components/AccessDenied.tsx b/src/dashboard/src/components/AccessDenied.tsx
index 479aac5..4d06241 100644
--- a/src/dashboard/src/components/AccessDenied.tsx
+++ b/src/dashboard/src/components/AccessDenied.tsx
@@ -1,18 +1,20 @@
import React from 'react';
-import { ShieldAlert, ArrowLeft } from 'lucide-react';
-import { useNavigate } from 'react-router-dom';
-import { useTranslation } from '../lib/i18n';
+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 {t} = useTranslation();
const navigate = useNavigate();
return (
-
-
+
+
@@ -25,7 +27,8 @@ export const AccessDenied: React.FC = () => {
-
+
{t('accessDenied.suggestion')}
@@ -33,7 +36,7 @@ export const AccessDenied: React.FC = () => {
onClick={() => 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/components/AuthCallback.tsx b/src/dashboard/src/components/AuthCallback.tsx
index 55cb230..3ef2630 100644
--- a/src/dashboard/src/components/AuthCallback.tsx
+++ b/src/dashboard/src/components/AuthCallback.tsx
@@ -1,13 +1,13 @@
-import { useEffect } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { Loader2 } from 'lucide-react';
-import { useAuth } from '../contexts/AuthContext';
-import { useTranslation } from '../lib/i18n';
+import {useEffect} from 'react';
+import {useNavigate} from 'react-router-dom';
+import {Loader2} from 'lucide-react';
+import {useAuth} from '../contexts/AuthContext';
+import {useTranslation} from '../lib/i18n';
export const AuthCallback = () => {
const navigate = useNavigate();
- const { checkAuth } = useAuth();
- const { t } = useTranslation();
+ const {checkAuth} = useAuth();
+ const {t} = useTranslation();
useEffect(() => {
const handleCallback = async () => {
@@ -36,7 +36,7 @@ export const AuthCallback = () => {
return (
-
+
{t('auth.loggingIn')}
diff --git a/src/dashboard/src/components/DeathConfirmModal.tsx b/src/dashboard/src/components/DeathConfirmModal.tsx
index 6bf5c65..5ac32b8 100644
--- a/src/dashboard/src/components/DeathConfirmModal.tsx
+++ b/src/dashboard/src/components/DeathConfirmModal.tsx
@@ -1,8 +1,8 @@
-import React, { useState } from 'react';
-import { useTranslation } from '../lib/i18n';
-import { Player } from '../types';
-import { Skull, X, AlertTriangle } from 'lucide-react';
-import { api } from '../lib/api';
+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;
@@ -10,8 +10,8 @@ interface DeathConfirmModalProps {
onClose: () => void;
}
-export const DeathConfirmModal: React.FC
= ({ player, guildId, onClose }) => {
- const { t } = useTranslation();
+export const DeathConfirmModal: React.FC = ({player, guildId, onClose}) => {
+ const {t} = useTranslation();
const [lastWords, setLastWords] = useState(false);
const [loading, setLoading] = useState(false);
@@ -30,22 +30,25 @@ export const DeathConfirmModal: React.FC = ({ player, gu
};
return (
-
-
-
+
+
+
-
+
{t('actions.kill')}
-
+
- {player.avatar ?
: '👤'}
+ {player.avatar ?
: '👤'}
{player.name}
@@ -55,7 +58,8 @@ export const DeathConfirmModal: React.FC
= ({ player, gu
-
+
{t('players.killConfirmation', 'Are you sure you want to kill this player?')}
@@ -67,7 +71,8 @@ export const DeathConfirmModal: React.FC
= ({ player, gu
onChange={(e) => 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')}
@@ -86,7 +91,7 @@ export const DeathConfirmModal: React.FC
= ({ player, gu
>
{loading ? '...' : (
<>
-
+
{t('actions.kill')}
>
)}
diff --git a/src/dashboard/src/components/GameHeader.tsx b/src/dashboard/src/components/GameHeader.tsx
index 299fcfb..d3f8c76 100644
--- a/src/dashboard/src/components/GameHeader.tsx
+++ b/src/dashboard/src/components/GameHeader.tsx
@@ -1,95 +1,111 @@
-import { Link } from 'react-router-dom';
-import { Sun, Moon, Play, Pause, SkipForward, Mic } from 'lucide-react';
-import { GamePhase, Player, SpeechState } from '../types';
-import { useTranslation } from '../lib/i18n';
+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;
+ 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";
+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;
+ const currentSpeaker = speech?.currentSpeakerId && players
+ ? players.find(p => p.id === speech.currentSpeakerId)
+ : null;
- return (
-
- );
+
+ {!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/components/GameLog.tsx b/src/dashboard/src/components/GameLog.tsx
index ac8c511..15c98ed 100644
--- a/src/dashboard/src/components/GameLog.tsx
+++ b/src/dashboard/src/components/GameLog.tsx
@@ -1,97 +1,118 @@
-import { useState } from 'react';
-import { MessageSquare, AlertTriangle } from 'lucide-react';
-import { LogEntry } from '../types';
-import { useTranslation } from '../lib/i18n';
+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;
+ 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);
+export const GameLog: React.FC = ({logs, onGlobalAction, readonly = false, className = ""}) => {
+ const {t} = useTranslation();
+ const [resetConfirming, setResetConfirming] = useState(false);
- return (
-
-
-
- {t('gameLog.title')}
-
-
+ return (
+
+
+
+ {t('gameLog.title')}
+
+
-
- {logs.map(log => (
-
-
{log.timestamp}
-
- {log.type === 'alert' &&
}
- {log.message}
+
+ {logs.map(log => (
+
+
{log.timestamp}
+
+ {log.type === 'alert' &&
}
+ {log.message}
+
+
+ ))}
-
- ))}
-
- {/* Admin Actions */}
- {!readonly && (
-
-
+ {/* 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')}
-
-
-
+ {/* 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')}
-
-
+ {/* 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')}
-
-
+ {/* 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/components/GameSettingsPage.tsx b/src/dashboard/src/components/GameSettingsPage.tsx
index 791228a..20002b5 100644
--- a/src/dashboard/src/components/GameSettingsPage.tsx
+++ b/src/dashboard/src/components/GameSettingsPage.tsx
@@ -1,14 +1,12 @@
-import React, { useState, useEffect, useRef } from 'react';
-import { RefreshCw, Loader2, Check, Plus, Minus, Users, AlertCircle, Dices } from 'lucide-react';
-import { ProgressOverlay } from './ProgressOverlay';
-import { useParams } from 'react-router-dom';
-import { useTranslation } from '../lib/i18n';
-import { api } from '../lib/api';
-import { useWebSocket } from '../lib/websocket';
+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 {guildId} = useParams<{ guildId: string }>();
+ const {t} = useTranslation();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -23,14 +21,6 @@ export const GameSettingsPage: React.FC = () => {
const [selectedRole, setSelectedRole] = useState
('');
const [updatingRoles, setUpdatingRoles] = useState(false);
- // Overlay State
- const [overlayVisible, setOverlayVisible] = useState(false);
- const [overlayStatus, setOverlayStatus] = useState<'processing' | 'success' | 'error'>('processing');
- const [overlayTitle, setOverlayTitle] = useState('');
- const [overlayLogs, setOverlayLogs] = useState([]);
- const [overlayError, setOverlayError] = useState(undefined);
- const [overlayProgress, setOverlayProgress] = useState(0);
-
const AVAILABLE_ROLES = [
"平民", "狼人", "女巫", "預言家", "獵人",
"守衛", "白痴", "騎士", "守墓人", "攝夢人", "魔術師",
@@ -93,7 +83,9 @@ export const GameSettingsPage: React.FC = () => {
console.error("Failed to load settings", e);
} finally {
setLoading(false);
- setTimeout(() => { isFirstLoad.current = false; }, 100);
+ setTimeout(() => {
+ isFirstLoad.current = false;
+ }, 100);
}
};
@@ -141,60 +133,27 @@ export const GameSettingsPage: React.FC = () => {
const handleRandomAssign = async () => {
if (!guildId) return;
-
- setOverlayTitle(t('messages.randomAssignRoles'));
- setOverlayVisible(true);
- setOverlayStatus('processing');
- setOverlayLogs([t('overlayMessages.requestingAssign')]);
- setOverlayError(undefined);
- setOverlayProgress(0);
-
try {
await api.assignRoles(guildId);
- setOverlayLogs(prev => [...prev]);
- setOverlayStatus('success');
- setOverlayLogs(prev => [...prev, t('overlayMessages.assignSuccess')]);
-
} catch (error: any) {
console.error("Assign failed", error);
- setOverlayStatus('error');
- const errorMessage = error.message || t('errors.unknownError');
- setOverlayLogs(prev => [...prev, `${t('errors.error')}: ${errorMessage}`]);
- setOverlayError(errorMessage);
}
};
const handlePlayerCountUpdate = async () => {
if (!guildId) return;
-
- setOverlayTitle(t('settings.playerCount'));
- setOverlayVisible(true);
- setOverlayStatus('processing');
- setOverlayLogs([t('overlayMessages.updatingPlayerCount')]);
- setOverlayError(undefined);
- setOverlayProgress(0);
-
try {
await api.setPlayerCount(guildId, playerCount);
- setOverlayProgress(100);
- setOverlayStatus('success');
- setOverlayLogs(prev => [...prev, t('overlayMessages.playerCountUpdateSuccess')]);
-
- // Reload settings to refresh exact state
loadSettings();
} catch (error: any) {
console.error("Update failed", error);
- setOverlayStatus('error');
- const errorMessage = error.message || t('errors.actionFailed', { action: t('buttons.update') });
- setOverlayLogs(prev => [...prev, `${t('errors.error')}: ${errorMessage}`]);
- setOverlayError(errorMessage);
}
};
if (loading) {
return (
-
+
);
}
@@ -210,7 +169,8 @@ export const GameSettingsPage: React.FC = () => {
-
{t('settings.muteAfterSpeech')}
+
{t('settings.muteAfterSpeech')}
{t('settings.muteAfterSpeechDesc')}
@@ -219,13 +179,14 @@ export const GameSettingsPage: React.FC = () => {
{(saving || justSaved) && (
{saving ? (
-
+
) : (
-
+
)}
)}
-
+
{
disabled={saving}
className="sr-only peer"
/>
-
+
-
{t('settings.doubleIdentities')}
+
{t('settings.doubleIdentities')}
{t('settings.doubleIdentitiesDesc')}
@@ -249,13 +212,14 @@ export const GameSettingsPage: React.FC = () => {
{(saving || justSaved) && (
{saving ? (
-
+
) : (
-
+
)}
)}
-
+
{
disabled={saving}
className="sr-only peer"
/>
-
+
@@ -305,13 +270,14 @@ export const GameSettingsPage: React.FC = () => {
{t('roles.title')}
- {t('messages.totalCount')}: {roles.length}
+ {t('messages.totalCount')}: {roles.length}
-
+
{t('messages.randomAssignRoles')}
@@ -330,7 +296,7 @@ export const GameSettingsPage: React.FC = () => {
/>
{AVAILABLE_ROLES.map(role => (
-
+
))}
@@ -339,7 +305,7 @@ export const GameSettingsPage: React.FC = () => {
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 ?
:
}
+ {updatingRoles ?
:
}
{t('messages.add')}
@@ -347,13 +313,15 @@ export const GameSettingsPage: React.FC = () => {
{/* Roles List */}
{Object.entries(roleCounts).sort((a, b) => b[1] - a[1]).map(([role, count]) => (
-
+
@@ -363,9 +331,10 @@ export const GameSettingsPage: React.FC = () => {
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}
{
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')}
)}
@@ -389,15 +359,6 @@ export const GameSettingsPage: React.FC = () => {
- setOverlayVisible(false)}
- />
>
);
};
diff --git a/src/dashboard/src/components/LoginScreen.tsx b/src/dashboard/src/components/LoginScreen.tsx
index 1c55013..588a6f1 100644
--- a/src/dashboard/src/components/LoginScreen.tsx
+++ b/src/dashboard/src/components/LoginScreen.tsx
@@ -1,45 +1,51 @@
-import { Moon } from 'lucide-react';
-import { useTranslation } from '../lib/i18n';
+import {Moon} from 'lucide-react';
+import {useTranslation} from '../lib/i18n';
interface LoginScreenProps {
- onLogin: () => void;
+ onLogin: () => void;
}
-export const LoginScreen: React.FC = ({ onLogin }) => {
- const { t } = useTranslation();
+export const LoginScreen: React.FC = ({onLogin}) => {
+ const {t} = useTranslation();
- return (
-
- {/* Background Effects */}
-
+ return (
+
+ {/* Background Effects */}
+
-
-
-
-
-
-
{t('login.title')}
-
{t('login.subtitle')}
-
+
+
+
+
+
+
{t('login.title')}
+
{t('login.subtitle')}
+
-
-
-
-
-
- {t('login.loginButton')}
-
-
- {t('login.restriction')}
-
+
+
+
+
+
+ {t('login.loginButton')}
+
+
+ {t('login.restriction')}
+
+
+
-
-
- );
+ );
};
diff --git a/src/dashboard/src/components/PlayerCard.tsx b/src/dashboard/src/components/PlayerCard.tsx
index 3f75580..cc4caf4 100644
--- a/src/dashboard/src/components/PlayerCard.tsx
+++ b/src/dashboard/src/components/PlayerCard.tsx
@@ -1,206 +1,223 @@
-import React, { useState, useEffect } from 'react';
-import { HeartPulse, Shield, Skull, MicOff, Settings, Lock, Unlock, ArrowLeftRight } from 'lucide-react';
-import { Player } from '../types';
-import { useTranslation } from '../lib/i18n';
+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;
+ 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";
+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 [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);
+ 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;
- }
+ 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);
+ }
}
- } catch (e) { /* ignore */ }
+ }, [player.roles, prevRoleString]);
- 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]);
- 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
- // handleKillClick removed, using onAction directly
+ return (
+
+
+ {/* Header */}
+
+
+
+ {player.avatar ? (
+
+ ) : (
+
+
+
+ )}
+ {player.isSheriff && (
+
+
+
+ )}
+ {player.isJinBaoBao && (
+
+
+
+ )}
- return (
-
-
- {/* Header */}
-
-
-
- {player.avatar ? (
-
- ) : (
-
-
-
- )}
- {player.isSheriff && (
-
-
-
- )}
- {player.isJinBaoBao && (
-
-
-
- )}
+ {/* Unlock Icon - Persistent if unlocked and has multiple roles */}
+ {player.roles.length > 1 && !player.rolePositionLocked && (
+
+
+
+ )}
- {/* 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 && (
-
+ {/* 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;
+ )}
+ {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] || ''}
+ 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}
- >
+ 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 &&
}
+
- {!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')}
-
+ {/* 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/components/PlayerEditModal.tsx b/src/dashboard/src/components/PlayerEditModal.tsx
index 1245c88..e06e12b 100644
--- a/src/dashboard/src/components/PlayerEditModal.tsx
+++ b/src/dashboard/src/components/PlayerEditModal.tsx
@@ -1,8 +1,8 @@
-import React, { useState } from 'react';
-import { useTranslation } from '../lib/i18n';
-import { Player } from '../types';
-import { Shield, X, ChevronRight, Users } from 'lucide-react';
-import { api } from '../lib/api';
+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;
@@ -13,8 +13,15 @@ interface PlayerEditModalProps {
availableRoles: string[];
}
-export const PlayerEditModal: React.FC
= ({ player, allPlayers, guildId, onClose, doubleIdentities, availableRoles }) => {
- const { t } = useTranslation();
+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);
@@ -83,15 +90,20 @@ export const PlayerEditModal: React.FC = ({ player, allPla
};
return (
-
-
-
+
+
+
- {player.avatar ? : '👤'}
+ {player.avatar ?
+ : '👤'}
{t('players.edit')} - {player.name}
-
-
+
+
@@ -99,15 +111,18 @@ export const PlayerEditModal: React.FC
= ({ player, allPla
{/* Role Editing Section */}
-
-
+
+
{t('roles.title' as any)}
-
+
-
{t('roles.role' as any)} 1
+
{t('roles.role' as any)} 1
= ({ player, allPla
{isDoubleIdentity && (
-
{t('roles.role' as any)} 2
+
{t('roles.role' as any)} 2
= ({ player, allPla
{isDoubleIdentity && (
-
{t('messages.lockRoleOrder')}
+
{t('messages.lockRoleOrder')}
= ({ player, allPla
onChange={(e) => setRolePositionLocked(e.target.checked)}
className="sr-only peer"
/>
-
+
@@ -166,11 +184,13 @@ export const PlayerEditModal: React.FC = ({ player, allPla
{/* Police Badge Transfer Section */}
{player.isSheriff ? (
-
-
+
+
{t('status.sheriff')}
-
+
{t('players.transferPoliceDescription', 'Transfer the police badge to another alive player.')}
@@ -193,7 +213,7 @@ export const PlayerEditModal: React.FC
= ({ player, allPla
onClick={handleTransferPolice}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-bold shadow-sm disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
- {loading ? '...' : }
+ {loading ? '...' : }
diff --git a/src/dashboard/src/components/PlayerSelectModal.tsx b/src/dashboard/src/components/PlayerSelectModal.tsx
index 3928263..978c2ad 100644
--- a/src/dashboard/src/components/PlayerSelectModal.tsx
+++ b/src/dashboard/src/components/PlayerSelectModal.tsx
@@ -1,7 +1,7 @@
-import React, { useState } from 'react';
-import { X, Search, Check } from 'lucide-react';
-import { useTranslation } from '../lib/i18n';
-import { Player } from '../types';
+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;
@@ -11,8 +11,8 @@ interface PlayerSelectModalProps {
filter?: (p: Player) => boolean;
}
-export const PlayerSelectModal: React.FC
= ({ title, players, onSelect, onClose, filter }) => {
- const { t } = useTranslation();
+export const PlayerSelectModal: React.FC = ({title, players, onSelect, onClose, filter}) => {
+ const {t} = useTranslation();
const [search, setSearch] = useState('');
const filteredPlayers = players
@@ -20,20 +20,24 @@ export const PlayerSelectModal: React.FC = ({ title, pla
.filter(p => p.name.toLowerCase().includes(search.toLowerCase()) || (p.userId && p.userId.includes(search)));
return (
-