From e409226484ce775b0b4e76aa1d09341dd3087f7c Mon Sep 17 00:00:00 2001 From: RobotHanzo <36107150+RobotHanzo@users.noreply.github.com> Date: Sat, 31 Jan 2026 03:58:06 +0800 Subject: [PATCH 1/5] placeholder dashboard --- .idea/compiler.xml | 2 +- .idea/copilot.data.migration.agent.xml | 6 + .idea/copilot.data.migration.edit.xml | 6 + build.gradle.kts | 6 +- src/dashboard/.gitignore | 24 + src/dashboard/README.md | 49 + src/dashboard/components/GameHeader.tsx | 63 + src/dashboard/components/GameLog.tsx | 56 + src/dashboard/components/IntegrationGuide.tsx | 134 ++ src/dashboard/components/LoginScreen.tsx | 44 + src/dashboard/components/PlayerCard.tsx | 85 ++ src/dashboard/components/RoleIcon.tsx | 16 + src/dashboard/components/Sidebar.tsx | 50 + src/dashboard/index.css | 12 + src/dashboard/index.html | 25 + src/dashboard/index.tsx | 194 +++ src/dashboard/metadata.json | 5 + src/dashboard/mockData.ts | 28 + src/dashboard/package.json | 26 + src/dashboard/postcss.config.js | 6 + src/dashboard/tailwind.config.js | 12 + src/dashboard/tsconfig.json | 25 + src/dashboard/tsconfig.node.json | 10 + src/dashboard/types.ts | 34 + src/dashboard/vite.config.ts | 7 + src/dashboard/yarn.lock | 1202 +++++++++++++++++ 26 files changed, 2123 insertions(+), 4 deletions(-) create mode 100644 .idea/copilot.data.migration.agent.xml create mode 100644 .idea/copilot.data.migration.edit.xml create mode 100644 src/dashboard/.gitignore create mode 100644 src/dashboard/README.md create mode 100644 src/dashboard/components/GameHeader.tsx create mode 100644 src/dashboard/components/GameLog.tsx create mode 100644 src/dashboard/components/IntegrationGuide.tsx create mode 100644 src/dashboard/components/LoginScreen.tsx create mode 100644 src/dashboard/components/PlayerCard.tsx create mode 100644 src/dashboard/components/RoleIcon.tsx create mode 100644 src/dashboard/components/Sidebar.tsx create mode 100644 src/dashboard/index.css create mode 100644 src/dashboard/index.html create mode 100644 src/dashboard/index.tsx create mode 100644 src/dashboard/metadata.json create mode 100644 src/dashboard/mockData.ts create mode 100644 src/dashboard/package.json create mode 100644 src/dashboard/postcss.config.js create mode 100644 src/dashboard/tailwind.config.js create mode 100644 src/dashboard/tsconfig.json create mode 100644 src/dashboard/tsconfig.node.json create mode 100644 src/dashboard/types.ts create mode 100644 src/dashboard/vite.config.ts create mode 100644 src/dashboard/yarn.lock diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 0448181..03c5b73 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -5,7 +5,7 @@ - + diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 0000000..4ea72a9 --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000..8648f94 --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 0698341..6fe894f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,12 +17,12 @@ repositories { dependencies { implementation("net.dv8tion:JDA:6.3.0") implementation("club.minnced:discord-webhooks:0.8.4") - implementation("org.mongodb:mongodb-driver-sync:5.1.3") + implementation("org.mongodb:mongodb-driver-sync:5.6.2") implementation("ch.qos.logback:logback-classic:1.5.7") implementation("com.github.RobotHanzo:JDAInteractions:v0.1.4") implementation("dev.arbjerg:lavaplayer:2.2.1") - compileOnly("org.projectlombok:lombok:1.18.34") - annotationProcessor("org.projectlombok:lombok:1.18.34") + compileOnly("org.projectlombok:lombok:1.18.38") + annotationProcessor("org.projectlombok:lombok:1.18.38") } tasks { diff --git a/src/dashboard/.gitignore b/src/dashboard/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/src/dashboard/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/src/dashboard/README.md b/src/dashboard/README.md new file mode 100644 index 0000000..12b4235 --- /dev/null +++ b/src/dashboard/README.md @@ -0,0 +1,49 @@ +# 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. + +## Prerequisites + +- [Node.js](https://nodejs.org/) (Version 16 or higher) +- [Yarn](https://yarnpkg.com/) + +## Installation + +1. Clone the repository (or download the source). +2. Install dependencies: + + ```bash + yarn install + ``` + +## Development + +To start the local development server: + +```bash +yarn dev +``` + +The application will be available at `http://localhost:5173`. + +## Production Build + +To build the application for production: + +```bash +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.). + +### Preview Production Build + +To preview the production build locally: + +```bash +yarn preview +``` + +## Integration + +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/components/GameHeader.tsx b/src/dashboard/components/GameHeader.tsx new file mode 100644 index 0000000..5cf565f --- /dev/null +++ b/src/dashboard/components/GameHeader.tsx @@ -0,0 +1,63 @@ + +import React from 'react'; +import { Sun, Moon, Play, Pause, SkipForward } from 'lucide-react'; +import { GamePhase } from '../types'; + +interface GameHeaderProps { + phase: GamePhase; + dayCount: number; + timerSeconds: number; + onGlobalAction: (action: string) => void; +} + +export const GameHeader: React.FC = ({ phase, dayCount, timerSeconds, onGlobalAction }) => { + 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"; + const btnSecondary = "bg-slate-700 hover:bg-slate-600 text-slate-200"; + + return ( +
+
+
+ Current Phase +
+ {phase === 'DAY' ? : } + {phase} {dayCount > 0 && `#${dayCount}`} +
+
+ +
+ +
+ Timer +
+ {Math.floor(timerSeconds / 60)}:{String(timerSeconds % 60).padStart(2, '0')} +
+
+
+ +
+ {phase === 'LOBBY' ? ( + + ) : ( + <> + + + + )} +
+
+ ); +}; diff --git a/src/dashboard/components/GameLog.tsx b/src/dashboard/components/GameLog.tsx new file mode 100644 index 0000000..ea8da09 --- /dev/null +++ b/src/dashboard/components/GameLog.tsx @@ -0,0 +1,56 @@ + +import React from 'react'; +import { MessageSquare, AlertTriangle } from 'lucide-react'; +import { LogEntry } from '../types'; + +interface GameLogProps { + logs: LogEntry[]; + onClear: () => void; + onManualCommand: (cmd: string) => void; +} + +export const GameLog: React.FC = ({ logs, onClear, onManualCommand }) => { + const inputStyle = "bg-slate-950 border border-slate-700 rounded-lg px-4 py-2 text-slate-200 focus:outline-none focus:border-indigo-500 w-full"; + + return ( +
+
+

+ Game Log +

+ +
+ +
+ {logs.map(log => ( +
+ {log.timestamp} +
+ {log.type === 'alert' && } + {log.message} +
+
+ ))} +
+ + {/* Console Input */} +
+ { + if (e.key === 'Enter') { + onManualCommand(e.currentTarget.value); + e.currentTarget.value = ''; + } + }} + /> +
+
+ ); +}; diff --git a/src/dashboard/components/IntegrationGuide.tsx b/src/dashboard/components/IntegrationGuide.tsx new file mode 100644 index 0000000..eb5ef57 --- /dev/null +++ b/src/dashboard/components/IntegrationGuide.tsx @@ -0,0 +1,134 @@ + +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 + +
+
+
+                {javaCode}
+              
+
+
+ +
+
+

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. +

+
+
+
+ +
+ +
+
+
+ ); +}; diff --git a/src/dashboard/components/LoginScreen.tsx b/src/dashboard/components/LoginScreen.tsx new file mode 100644 index 0000000..c128133 --- /dev/null +++ b/src/dashboard/components/LoginScreen.tsx @@ -0,0 +1,44 @@ + +import React from 'react'; +import { Moon } from 'lucide-react'; + +interface LoginScreenProps { + onLogin: () => void; +} + +export const LoginScreen: React.FC = ({ onLogin }) => { + return ( +
+ {/* Background Effects */} +
+
+
+
+ +
+
+
+ +
+

Werewolf Helper

+

Admin Dashboard & Game Manager

+
+ +
+ +
+ Restricted to Admins of the Werewolf Server +
+
+
+
+ ); +}; diff --git a/src/dashboard/components/PlayerCard.tsx b/src/dashboard/components/PlayerCard.tsx new file mode 100644 index 0000000..320c330 --- /dev/null +++ b/src/dashboard/components/PlayerCard.tsx @@ -0,0 +1,85 @@ + +import React from 'react'; +import { BadgeAlert, HeartPulse, Shield, Skull, MicOff, Settings } from 'lucide-react'; +import { Player } from '../types'; + +interface PlayerCardProps { + player: Player; + onAction: (id: string, action: string) => void; +} + +export const PlayerCard: React.FC = ({ player, onAction }) => { + const cardStyle = "bg-slate-800/50 rounded-xl border border-slate-700/50 hover:border-indigo-500/50 transition-all duration-200 overflow-hidden relative group"; + + return ( +
+
+ {/* Header */} +
+
+
+ {player.name} + {player.isSheriff && ( +
+ +
+ )} + {player.isJinBaoBao && ( +
+ +
+ )} +
+
+

{player.name}

+
+ + {player.role} + + {!player.isAlive && DEAD} +
+
+
+
+ {player.isProtected &&
} + {player.isPoisoned &&
} + {player.isSilenced &&
} +
+
+ + {/* Actions (Admin) */} +
+ {player.isAlive ? ( + + ) : ( + + )} + +
+
+
+ ); +}; diff --git a/src/dashboard/components/RoleIcon.tsx b/src/dashboard/components/RoleIcon.tsx new file mode 100644 index 0000000..a5e891b --- /dev/null +++ b/src/dashboard/components/RoleIcon.tsx @@ -0,0 +1,16 @@ + +import React from 'react'; +import { Moon, Eye, Zap, Swords, Shield, Users } from 'lucide-react'; +import { Role } from '../types'; + +export const RoleIcon = ({ role }: { role: Role }) => { + switch (role) { + case 'WEREWOLF': return ; + case 'SEER': return ; + case 'WITCH': return ; + case 'HUNTER': return ; + case 'GUARD': return ; + case 'VILLAGER': return ; + default: return ; + } +}; diff --git a/src/dashboard/components/Sidebar.tsx b/src/dashboard/components/Sidebar.tsx new file mode 100644 index 0000000..e92ef19 --- /dev/null +++ b/src/dashboard/components/Sidebar.tsx @@ -0,0 +1,50 @@ + +import React from 'react'; +import { Moon, Activity, Settings, Code, LogOut } from 'lucide-react'; + +interface SidebarProps { + onLogout: () => void; + onShowGuide: () => void; +} + +export const Sidebar: React.FC = ({ onLogout, onShowGuide }) => { + return ( + + ); +}; diff --git a/src/dashboard/index.css b/src/dashboard/index.css new file mode 100644 index 0000000..49e37d8 --- /dev/null +++ b/src/dashboard/index.css @@ -0,0 +1,12 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom Scrollbar */ +.scrollbar-hide::-webkit-scrollbar { + display: none; +} +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} diff --git a/src/dashboard/index.html b/src/dashboard/index.html new file mode 100644 index 0000000..1ea06c1 --- /dev/null +++ b/src/dashboard/index.html @@ -0,0 +1,25 @@ + + + + + + Werewolf Helper Dashboard + + + + +
+ + + \ No newline at end of file diff --git a/src/dashboard/index.tsx b/src/dashboard/index.tsx new file mode 100644 index 0000000..955c6eb --- /dev/null +++ b/src/dashboard/index.tsx @@ -0,0 +1,194 @@ +import React, { useState, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Users } from 'lucide-react'; +import './index.css'; // Import Tailwind directives + +import { GameState, GamePhase } from './types'; +import { INITIAL_PLAYERS } from './mockData'; +import { LoginScreen } from './components/LoginScreen'; +import { Sidebar } from './components/Sidebar'; +import { GameHeader } from './components/GameHeader'; +import { PlayerCard } from './components/PlayerCard'; +import { GameLog } from './components/GameLog'; +import { IntegrationGuide } from './components/IntegrationGuide'; + +const App = () => { + // State + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [showIntegrationGuide, setShowIntegrationGuide] = useState(false); + const [gameState, setGameState] = useState({ + phase: 'LOBBY', + dayCount: 0, + timerSeconds: 0, + players: INITIAL_PLAYERS, + logs: [{ id: '1', timestamp: '12:00:00', message: 'System initialized. Waiting for connection...', type: 'info' }], + }); + + // Simulation Logic (Simulating Real-time updates) + useEffect(() => { + if (!isAuthenticated) return; + + // Simulate WebSocket heartbeat / polling + const interval = setInterval(() => { + setGameState(prev => { + // Decrease timer if active + let newTimer = prev.timerSeconds; + let newPhase = prev.phase; + + if (prev.phase !== 'LOBBY' && prev.phase !== 'GAME_OVER' && prev.timerSeconds > 0) { + newTimer -= 1; + } + + return { + ...prev, + timerSeconds: newTimer, + phase: newPhase + }; + }); + }, 1000); + + return () => clearInterval(interval); + }, [isAuthenticated]); + + // Auth Simulation + const handleLogin = () => { + setTimeout(() => { + setIsAuthenticated(true); + addLog('Admin logged in via Discord OAuth simulation.'); + }, 800); + }; + + const handleAction = (playerId: string, actionType: string) => { + // Simulate API call to Java Bot + addLog(`Admin executed command: /${actionType} on ${playerId}`); + + setGameState(prev => { + const updatedPlayers = prev.players.map(p => { + if (p.id === playerId) { + if (actionType === 'kill') return { ...p, isAlive: false }; + if (actionType === 'revive') return { ...p, isAlive: true }; + if (actionType === 'toggle-jin') return { ...p, isJinBaoBao: !p.isJinBaoBao }; + } + return p; + }); + return { ...prev, players: updatedPlayers }; + }); + }; + + const handleGlobalAction = (action: string) => { + addLog(`Admin executed global command: /${action}`); + + if (action === 'start_game') { + setGameState(prev => ({ + ...prev, + phase: 'NIGHT', + dayCount: 1, + timerSeconds: 30, + logs: [...prev.logs, { id: Date.now().toString(), timestamp: new Date().toLocaleTimeString(), message: 'Game Started!', 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('Game timer paused by Admin.'); + } else if (action === 'reset') { + setGameState(prev => ({ + ...prev, + phase: 'LOBBY', + dayCount: 0, + timerSeconds: 0, + players: INITIAL_PLAYERS, + logs: [...prev.logs, { id: Date.now().toString(), timestamp: new Date().toLocaleTimeString(), message: 'Game Reset.', type: 'alert' }] + })); + } else if (action === 'broadcast_role') { + addLog('Broadcasting roles to all players via DM...'); + } else if (action === 'random_assign') { + addLog('Randomizing roles...'); + } + }; + + const addLog = (msg: string) => { + setGameState(prev => ({ + ...prev, + logs: [{ + id: Date.now().toString(), + timestamp: new Date().toLocaleTimeString(), + message: msg, + type: 'info' as const + }, ...prev.logs].slice(0, 50) + })); + }; + + if (!isAuthenticated) { + return ; + } + + return ( +
+ setIsAuthenticated(false)} onShowGuide={() => setShowIntegrationGuide(true)} /> + +
+ + +
+
+ + {/* Left: Players Grid */} +
+
+

+ + Players ({gameState.players.filter(p => p.isAlive).length} Alive) +

+
+ +
+ {gameState.players.map(player => ( + + ))} +
+ + {/* Quick Actions Bar */} +
+

Global Admin Commands

+
+ + + +
+
+
+ + {/* Right: Logs & Event Stream */} + setGameState(prev => ({...prev, logs: []}))} + onManualCommand={(cmd) => addLog(`Manual command: ${cmd}`)} + /> + +
+
+ + {/* Modals */} + {showIntegrationGuide && setShowIntegrationGuide(false)} />} +
+
+ ); +}; + +const root = createRoot(document.getElementById('root')!); +root.render(); \ No newline at end of file diff --git a/src/dashboard/metadata.json b/src/dashboard/metadata.json new file mode 100644 index 0000000..1caf70c --- /dev/null +++ b/src/dashboard/metadata.json @@ -0,0 +1,5 @@ +{ + "description": "Generated by Gemini.", + "requestFramePermissions": [], + "name": "App" +} \ No newline at end of file diff --git a/src/dashboard/mockData.ts b/src/dashboard/mockData.ts new file mode 100644 index 0000000..db18200 --- /dev/null +++ b/src/dashboard/mockData.ts @@ -0,0 +1,28 @@ + +import { Player } from './types'; + +export const MOCK_AVATARS = [ + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Zack', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=John', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Mila', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Leo', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Kai', +]; + +export const INITIAL_PLAYERS: Player[] = Array.from({ length: 8 }).map((_, i) => ({ + id: `p-${i}`, + discordId: `u-${i}`, + name: `Player ${i + 1}`, + avatar: MOCK_AVATARS[i], + role: i === 0 ? 'WEREWOLF' : i === 1 ? 'SEER' : i === 2 ? 'WITCH' : 'VILLAGER', + isAlive: true, + isSheriff: false, + isJinBaoBao: i === 3, + isProtected: false, + isPoisoned: false, + isSilenced: false, + hasVoted: false, +})); diff --git a/src/dashboard/package.json b/src/dashboard/package.json new file mode 100644 index 0000000..38acfc2 --- /dev/null +++ b/src/dashboard/package.json @@ -0,0 +1,26 @@ +{ + "name": "werewolf-helper-dashboard", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.344.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.21", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.18", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.2.2", + "vite": "^5.1.6" + } +} \ No newline at end of file diff --git a/src/dashboard/postcss.config.js b/src/dashboard/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/src/dashboard/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/src/dashboard/tailwind.config.js b/src/dashboard/tailwind.config.js new file mode 100644 index 0000000..0341812 --- /dev/null +++ b/src/dashboard/tailwind.config.js @@ -0,0 +1,12 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./components/**/*.{js,ts,jsx,tsx}", + "./*.{ts,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} \ No newline at end of file diff --git a/src/dashboard/tsconfig.json b/src/dashboard/tsconfig.json new file mode 100644 index 0000000..1067bd0 --- /dev/null +++ b/src/dashboard/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["."], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/src/dashboard/tsconfig.node.json b/src/dashboard/tsconfig.node.json new file mode 100644 index 0000000..099658c --- /dev/null +++ b/src/dashboard/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/src/dashboard/types.ts b/src/dashboard/types.ts new file mode 100644 index 0000000..c05fb7b --- /dev/null +++ b/src/dashboard/types.ts @@ -0,0 +1,34 @@ + +export type Role = 'WEREWOLF' | 'VILLAGER' | 'SEER' | 'WITCH' | 'HUNTER' | 'GUARD' | 'IDIOT' | 'WOLF_KING'; +export type GamePhase = 'LOBBY' | 'NIGHT' | 'DAY' | 'VOTING' | 'GAME_OVER'; + +export interface Player { + id: string; + discordId: string; + name: string; + avatar: string; + role: Role; + isAlive: boolean; + isSheriff: boolean; + isJinBaoBao: boolean; + isProtected: boolean; + isPoisoned: boolean; + isSilenced: boolean; + hasVoted: 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; +} diff --git a/src/dashboard/vite.config.ts b/src/dashboard/vite.config.ts new file mode 100644 index 0000000..2dea53a --- /dev/null +++ b/src/dashboard/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) \ No newline at end of file diff --git a/src/dashboard/yarn.lock b/src/dashboard/yarn.lock new file mode 100644 index 0000000..ecfb6bc --- /dev/null +++ b/src/dashboard/yarn.lock @@ -0,0 +1,1202 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@alloc/quick-lru@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" + integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== + +"@babel/code-frame@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.28.6.tgz#72499312ec58b1e2245ba4a4f550c132be4982f7" + integrity sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.6.tgz#103f466803fa0f059e82ccac271475470570d74c" + integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg== + +"@babel/core@^7.28.0": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.6.tgz#531bf883a1126e53501ba46eb3bb414047af507f" + integrity sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/generator" "^7.28.6" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helpers" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.6.tgz#48dcc65d98fcc8626a48f72b62e263d25fc3c3f1" + integrity sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw== + dependencies: + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-compilation-targets@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25" + integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== + dependencies: + "@babel/compat-data" "^7.28.6" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + +"@babel/helper-module-imports@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz#60632cbd6ffb70b22823187201116762a03e2d5c" + integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== + dependencies: + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/helper-module-transforms@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e" + integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== + dependencies: + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.28.6" + +"@babel/helper-plugin-utils@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + +"@babel/helpers@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7" + integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== + dependencies: + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.6.tgz#f01a8885b7fa1e56dd8a155130226cd698ef13fd" + integrity sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ== + dependencies: + "@babel/types" "^7.28.6" + +"@babel/plugin-transform-react-jsx-self@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz#af678d8506acf52c577cac73ff7fe6615c85fc92" + integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-react-jsx-source@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz#dcfe2c24094bb757bf73960374e7c55e434f19f0" + integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/template@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/traverse@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.6.tgz#871ddc79a80599a5030c53b1cc48cbe3a5583c2e" + integrity sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/generator" "^7.28.6" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.6" + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" + debug "^4.3.1" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.28.2", "@babel/types@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.6.tgz#c3e9377f1b155005bcc4c46020e7e394e13089df" + integrity sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@rolldown/pluginutils@1.0.0-beta.27": + version "1.0.0-beta.27" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz#47d2bf4cef6d470b22f5831b420f8964e0bf755f" + integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA== + +"@rollup/rollup-android-arm-eabi@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088" + integrity sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg== + +"@rollup/rollup-android-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz#10bd0382b73592beee6e9800a69401a29da625c4" + integrity sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w== + +"@rollup/rollup-darwin-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz#1e99ab04c0b8c619dd7bbde725ba2b87b55bfd81" + integrity sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg== + +"@rollup/rollup-darwin-x64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz#69e741aeb2839d2e8f0da2ce7a33d8bd23632423" + integrity sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w== + +"@rollup/rollup-freebsd-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz#3736c232a999c7bef7131355d83ebdf9651a0839" + integrity sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug== + +"@rollup/rollup-freebsd-x64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz#227dcb8f466684070169942bd3998901c9bfc065" + integrity sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz#ba004b30df31b724f99ce66e7128248bea17cb0c" + integrity sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw== + +"@rollup/rollup-linux-arm-musleabihf@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz#6929f3e07be6b6da5991f63c6b68b3e473d0a65a" + integrity sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw== + +"@rollup/rollup-linux-arm64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz#06e89fd4a25d21fe5575d60b6f913c0e65297bfa" + integrity sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g== + +"@rollup/rollup-linux-arm64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz#fddabf395b90990d5194038e6cd8c00156ed8ac0" + integrity sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q== + +"@rollup/rollup-linux-loong64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz#04c10bb764bbf09a3c1bd90432e92f58d6603c36" + integrity sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA== + +"@rollup/rollup-linux-loong64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz#f2450361790de80581d8687ea19142d8a4de5c0f" + integrity sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw== + +"@rollup/rollup-linux-ppc64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz#0474f4667259e407eee1a6d38e29041b708f6a30" + integrity sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w== + +"@rollup/rollup-linux-ppc64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz#9f32074819eeb1ddbe51f50ea9dcd61a6745ec33" + integrity sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw== + +"@rollup/rollup-linux-riscv64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz#3fdb9d4b1e29fb6b6a6da9f15654d42eb77b99b2" + integrity sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A== + +"@rollup/rollup-linux-riscv64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz#1de780d64e6be0e3e8762035c22e0d8ea68df8ed" + integrity sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw== + +"@rollup/rollup-linux-s390x-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz#1da022ffd2d9e9f0fd8344ea49e113001fbcac64" + integrity sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg== + +"@rollup/rollup-linux-x64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz#78c16eef9520bd10e1ea7a112593bb58e2842622" + integrity sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg== + +"@rollup/rollup-linux-x64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz#a7598591b4d9af96cb3167b50a5bf1e02dfea06c" + integrity sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw== + +"@rollup/rollup-openbsd-x64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz#c51d48c07cd6c466560e5bed934aec688ce02614" + integrity sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw== + +"@rollup/rollup-openharmony-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz#f09921d0b2a0b60afbf3586d2a7a7f208ba6df17" + integrity sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ== + +"@rollup/rollup-win32-arm64-msvc@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz#08d491717135376e4a99529821c94ecd433d5b36" + integrity sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ== + +"@rollup/rollup-win32-ia32-msvc@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz#b0c12aac1104a8b8f26a5e0098e5facbb3e3964a" + integrity sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew== + +"@rollup/rollup-win32-x64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz#b9cccef26f5e6fdc013bf3c0911a3c77428509d0" + integrity sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ== + +"@rollup/rollup-win32-x64-msvc@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz#a03348e7b559c792b6277cc58874b89ef46e1e72" + integrity sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA== + +"@types/babel__core@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9" + integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74" + integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== + dependencies: + "@babel/types" "^7.28.2" + +"@types/estree@1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/prop-types@*": + version "15.7.15" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" + integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== + +"@types/react-dom@^18.2.21": + version "18.3.7" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" + integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== + +"@types/react@^18.2.64": + version "18.3.27" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.27.tgz#74a3b590ea183983dc65a474dc17553ae1415c34" + integrity sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w== + dependencies: + "@types/prop-types" "*" + csstype "^3.2.2" + +"@vitejs/plugin-react@^4.2.1": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz#647af4e7bb75ad3add578e762ad984b90f4a24b9" + integrity sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA== + dependencies: + "@babel/core" "^7.28.0" + "@babel/plugin-transform-react-jsx-self" "^7.27.1" + "@babel/plugin-transform-react-jsx-source" "^7.27.1" + "@rolldown/pluginutils" "1.0.0-beta.27" + "@types/babel__core" "^7.20.5" + react-refresh "^0.17.0" + +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +arg@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" + integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== + +autoprefixer@^10.4.18: + version "10.4.23" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.23.tgz#c6aa6db8e7376fcd900f9fd79d143ceebad8c4e6" + integrity sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA== + dependencies: + browserslist "^4.28.1" + caniuse-lite "^1.0.30001760" + fraction.js "^5.3.4" + picocolors "^1.1.1" + postcss-value-parser "^4.2.0" + +baseline-browser-mapping@^2.9.0: + version "2.9.19" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488" + integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg== + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +braces@^3.0.3, braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browserslist@^4.24.0, browserslist@^4.28.1: + version "4.28.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== + dependencies: + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" + +camelcase-css@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" + integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== + +caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001760: + version "1.0.30001766" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a" + integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA== + +chokidar@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +commander@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +debug@^4.1.0, debug@^4.3.1: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +didyoumean@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" + integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== + +dlv@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" + integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== + +electron-to-chromium@^1.5.263: + version "1.5.283" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz#51d492c37c2d845a0dccb113fe594880c8616de8" + integrity sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w== + +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + +escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +fast-glob@^3.3.2: + version "3.3.3" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + +fastq@^1.6.0: + version "1.20.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675" + integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw== + dependencies: + reusify "^1.0.4" + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +fraction.js@^5.3.4: + version "5.3.4" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a" + integrity sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +glob-parent@^5.1.2, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +jiti@^1.21.7: + version "1.21.7" + resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9" + integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A== + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +lilconfig@^3.1.1, lilconfig@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4" + integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +lucide-react@^0.344.0: + version "0.344.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.344.0.tgz#fcbc7cf855e6baedbc14aab6ddca09b7c1afc46d" + integrity sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ== + +merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +mz@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +node-releases@^2.0.27: + version "2.0.27" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" + integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +object-assign@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-hash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" + integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + +pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== + +pirates@^4.0.1: + version "4.0.7" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== + +postcss-import@^15.1.0: + version "15.1.0" + resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70" + integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew== + dependencies: + postcss-value-parser "^4.0.0" + read-cache "^1.0.0" + resolve "^1.1.7" + +postcss-js@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.1.0.tgz#003b63c6edde948766e40f3daf7e997ae43a5ce6" + integrity sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw== + dependencies: + camelcase-css "^2.0.1" + +"postcss-load-config@^4.0.2 || ^5.0 || ^6.0": + version "6.0.1" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz#6fd7dcd8ae89badcf1b2d644489cbabf83aa8096" + integrity sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g== + dependencies: + lilconfig "^3.1.1" + +postcss-nested@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.2.0.tgz#4c2d22ab5f20b9cb61e2c5c5915950784d068131" + integrity sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ== + dependencies: + postcss-selector-parser "^6.1.1" + +postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2: + version "6.1.2" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de" + integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +postcss@^8.4.35, postcss@^8.4.43, postcss@^8.4.47: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +react-dom@^18.2.0: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + +react-refresh@^0.17.0: + version "0.17.0" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" + integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== + +react@^18.2.0: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + +read-cache@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" + integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA== + dependencies: + pify "^2.3.0" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +resolve@^1.1.7, resolve@^1.22.8: + version "1.22.11" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" + integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== + dependencies: + is-core-module "^2.16.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +reusify@^1.0.4: + version "1.1.0" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== + +rollup@^4.20.0: + version "4.57.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.1.tgz#947f70baca32db2b9c594267fe9150aa316e5a88" + integrity sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.57.1" + "@rollup/rollup-android-arm64" "4.57.1" + "@rollup/rollup-darwin-arm64" "4.57.1" + "@rollup/rollup-darwin-x64" "4.57.1" + "@rollup/rollup-freebsd-arm64" "4.57.1" + "@rollup/rollup-freebsd-x64" "4.57.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.57.1" + "@rollup/rollup-linux-arm-musleabihf" "4.57.1" + "@rollup/rollup-linux-arm64-gnu" "4.57.1" + "@rollup/rollup-linux-arm64-musl" "4.57.1" + "@rollup/rollup-linux-loong64-gnu" "4.57.1" + "@rollup/rollup-linux-loong64-musl" "4.57.1" + "@rollup/rollup-linux-ppc64-gnu" "4.57.1" + "@rollup/rollup-linux-ppc64-musl" "4.57.1" + "@rollup/rollup-linux-riscv64-gnu" "4.57.1" + "@rollup/rollup-linux-riscv64-musl" "4.57.1" + "@rollup/rollup-linux-s390x-gnu" "4.57.1" + "@rollup/rollup-linux-x64-gnu" "4.57.1" + "@rollup/rollup-linux-x64-musl" "4.57.1" + "@rollup/rollup-openbsd-x64" "4.57.1" + "@rollup/rollup-openharmony-arm64" "4.57.1" + "@rollup/rollup-win32-arm64-msvc" "4.57.1" + "@rollup/rollup-win32-ia32-msvc" "4.57.1" + "@rollup/rollup-win32-x64-gnu" "4.57.1" + "@rollup/rollup-win32-x64-msvc" "4.57.1" + fsevents "~2.3.2" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +sucrase@^3.35.0: + version "3.35.1" + resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.1.tgz#4619ea50393fe8bd0ae5071c26abd9b2e346bfe1" + integrity sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.2" + commander "^4.0.0" + lines-and-columns "^1.1.6" + mz "^2.7.0" + pirates "^4.0.1" + tinyglobby "^0.2.11" + ts-interface-checker "^0.1.9" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +tailwindcss@^3.4.1: + version "3.4.19" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.19.tgz#af2a0a4ae302d52ebe078b6775e799e132500ee2" + integrity sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ== + dependencies: + "@alloc/quick-lru" "^5.2.0" + arg "^5.0.2" + chokidar "^3.6.0" + didyoumean "^1.2.2" + dlv "^1.1.3" + fast-glob "^3.3.2" + glob-parent "^6.0.2" + is-glob "^4.0.3" + jiti "^1.21.7" + lilconfig "^3.1.3" + micromatch "^4.0.8" + normalize-path "^3.0.0" + object-hash "^3.0.0" + picocolors "^1.1.1" + postcss "^8.4.47" + postcss-import "^15.1.0" + postcss-js "^4.0.1" + postcss-load-config "^4.0.2 || ^5.0 || ^6.0" + postcss-nested "^6.2.0" + postcss-selector-parser "^6.1.2" + resolve "^1.22.8" + sucrase "^3.35.0" + +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.1" + resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== + dependencies: + any-promise "^1.0.0" + +tinyglobby@^0.2.11: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +ts-interface-checker@^0.1.9: + version "0.1.13" + resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" + integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== + +typescript@^5.2.2: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +update-browserslist-db@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +util-deprecate@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +vite@^5.1.6: + version "5.4.21" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027" + integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== From 35b972014d3804082f00d2b6be42b63f884d1e30 Mon Sep 17 00:00:00 2001 From: RobotHanzo <36107150+RobotHanzo@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:08:35 +0800 Subject: [PATCH 2/5] better placeholder dashboard --- src/dashboard/components/GameHeader.tsx | 63 ------ src/dashboard/components/GameLog.tsx | 56 ----- src/dashboard/components/PlayerCard.tsx | 85 -------- src/dashboard/components/Sidebar.tsx | 50 ----- src/dashboard/index.html | 8 +- src/dashboard/index.tsx | 194 ------------------ src/dashboard/src/App.tsx | 138 +++++++++++++ src/dashboard/src/components/GameHeader.tsx | 63 ++++++ src/dashboard/src/components/GameLog.tsx | 55 +++++ .../{ => src}/components/IntegrationGuide.tsx | 0 .../{ => src}/components/LoginScreen.tsx | 25 +-- src/dashboard/src/components/PlayerCard.tsx | 84 ++++++++ .../{ => src}/components/RoleIcon.tsx | 0 src/dashboard/src/components/Sidebar.tsx | 58 ++++++ src/dashboard/src/components/ThemeToggle.tsx | 20 ++ src/dashboard/{ => src}/index.css | 10 + src/dashboard/src/lib/ThemeProvider.tsx | 61 ++++++ src/dashboard/src/lib/i18n.ts | 38 ++++ src/dashboard/src/locales/zh-TW.json | 101 +++++++++ src/dashboard/src/main.tsx | 11 + src/dashboard/{ => src}/mockData.ts | 0 src/dashboard/{ => src}/types.ts | 0 src/dashboard/tailwind.config.js | 4 +- 23 files changed, 658 insertions(+), 466 deletions(-) delete mode 100644 src/dashboard/components/GameHeader.tsx delete mode 100644 src/dashboard/components/GameLog.tsx delete mode 100644 src/dashboard/components/PlayerCard.tsx delete mode 100644 src/dashboard/components/Sidebar.tsx delete mode 100644 src/dashboard/index.tsx create mode 100644 src/dashboard/src/App.tsx create mode 100644 src/dashboard/src/components/GameHeader.tsx create mode 100644 src/dashboard/src/components/GameLog.tsx rename src/dashboard/{ => src}/components/IntegrationGuide.tsx (100%) rename src/dashboard/{ => src}/components/LoginScreen.tsx (62%) create mode 100644 src/dashboard/src/components/PlayerCard.tsx rename src/dashboard/{ => src}/components/RoleIcon.tsx (100%) create mode 100644 src/dashboard/src/components/Sidebar.tsx create mode 100644 src/dashboard/src/components/ThemeToggle.tsx rename src/dashboard/{ => src}/index.css (53%) create mode 100644 src/dashboard/src/lib/ThemeProvider.tsx create mode 100644 src/dashboard/src/lib/i18n.ts create mode 100644 src/dashboard/src/locales/zh-TW.json create mode 100644 src/dashboard/src/main.tsx rename src/dashboard/{ => src}/mockData.ts (100%) rename src/dashboard/{ => src}/types.ts (100%) diff --git a/src/dashboard/components/GameHeader.tsx b/src/dashboard/components/GameHeader.tsx deleted file mode 100644 index 5cf565f..0000000 --- a/src/dashboard/components/GameHeader.tsx +++ /dev/null @@ -1,63 +0,0 @@ - -import React from 'react'; -import { Sun, Moon, Play, Pause, SkipForward } from 'lucide-react'; -import { GamePhase } from '../types'; - -interface GameHeaderProps { - phase: GamePhase; - dayCount: number; - timerSeconds: number; - onGlobalAction: (action: string) => void; -} - -export const GameHeader: React.FC = ({ phase, dayCount, timerSeconds, onGlobalAction }) => { - 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"; - const btnSecondary = "bg-slate-700 hover:bg-slate-600 text-slate-200"; - - return ( -
-
-
- Current Phase -
- {phase === 'DAY' ? : } - {phase} {dayCount > 0 && `#${dayCount}`} -
-
- -
- -
- Timer -
- {Math.floor(timerSeconds / 60)}:{String(timerSeconds % 60).padStart(2, '0')} -
-
-
- -
- {phase === 'LOBBY' ? ( - - ) : ( - <> - - - - )} -
-
- ); -}; diff --git a/src/dashboard/components/GameLog.tsx b/src/dashboard/components/GameLog.tsx deleted file mode 100644 index ea8da09..0000000 --- a/src/dashboard/components/GameLog.tsx +++ /dev/null @@ -1,56 +0,0 @@ - -import React from 'react'; -import { MessageSquare, AlertTriangle } from 'lucide-react'; -import { LogEntry } from '../types'; - -interface GameLogProps { - logs: LogEntry[]; - onClear: () => void; - onManualCommand: (cmd: string) => void; -} - -export const GameLog: React.FC = ({ logs, onClear, onManualCommand }) => { - const inputStyle = "bg-slate-950 border border-slate-700 rounded-lg px-4 py-2 text-slate-200 focus:outline-none focus:border-indigo-500 w-full"; - - return ( -
-
-

- Game Log -

- -
- -
- {logs.map(log => ( -
- {log.timestamp} -
- {log.type === 'alert' && } - {log.message} -
-
- ))} -
- - {/* Console Input */} -
- { - if (e.key === 'Enter') { - onManualCommand(e.currentTarget.value); - e.currentTarget.value = ''; - } - }} - /> -
-
- ); -}; diff --git a/src/dashboard/components/PlayerCard.tsx b/src/dashboard/components/PlayerCard.tsx deleted file mode 100644 index 320c330..0000000 --- a/src/dashboard/components/PlayerCard.tsx +++ /dev/null @@ -1,85 +0,0 @@ - -import React from 'react'; -import { BadgeAlert, HeartPulse, Shield, Skull, MicOff, Settings } from 'lucide-react'; -import { Player } from '../types'; - -interface PlayerCardProps { - player: Player; - onAction: (id: string, action: string) => void; -} - -export const PlayerCard: React.FC = ({ player, onAction }) => { - const cardStyle = "bg-slate-800/50 rounded-xl border border-slate-700/50 hover:border-indigo-500/50 transition-all duration-200 overflow-hidden relative group"; - - return ( -
-
- {/* Header */} -
-
-
- {player.name} - {player.isSheriff && ( -
- -
- )} - {player.isJinBaoBao && ( -
- -
- )} -
-
-

{player.name}

-
- - {player.role} - - {!player.isAlive && DEAD} -
-
-
-
- {player.isProtected &&
} - {player.isPoisoned &&
} - {player.isSilenced &&
} -
-
- - {/* Actions (Admin) */} -
- {player.isAlive ? ( - - ) : ( - - )} - -
-
-
- ); -}; diff --git a/src/dashboard/components/Sidebar.tsx b/src/dashboard/components/Sidebar.tsx deleted file mode 100644 index e92ef19..0000000 --- a/src/dashboard/components/Sidebar.tsx +++ /dev/null @@ -1,50 +0,0 @@ - -import React from 'react'; -import { Moon, Activity, Settings, Code, LogOut } from 'lucide-react'; - -interface SidebarProps { - onLogout: () => void; - onShowGuide: () => void; -} - -export const Sidebar: React.FC = ({ onLogout, onShowGuide }) => { - return ( - - ); -}; diff --git a/src/dashboard/index.html b/src/dashboard/index.html index 1ea06c1..6eebae0 100644 --- a/src/dashboard/index.html +++ b/src/dashboard/index.html @@ -1,9 +1,9 @@ - + - Werewolf Helper Dashboard + 狼人殺助手 - 管理員儀表板 - +
- + \ No newline at end of file diff --git a/src/dashboard/index.tsx b/src/dashboard/index.tsx deleted file mode 100644 index 955c6eb..0000000 --- a/src/dashboard/index.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { createRoot } from 'react-dom/client'; -import { Users } from 'lucide-react'; -import './index.css'; // Import Tailwind directives - -import { GameState, GamePhase } from './types'; -import { INITIAL_PLAYERS } from './mockData'; -import { LoginScreen } from './components/LoginScreen'; -import { Sidebar } from './components/Sidebar'; -import { GameHeader } from './components/GameHeader'; -import { PlayerCard } from './components/PlayerCard'; -import { GameLog } from './components/GameLog'; -import { IntegrationGuide } from './components/IntegrationGuide'; - -const App = () => { - // State - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [showIntegrationGuide, setShowIntegrationGuide] = useState(false); - const [gameState, setGameState] = useState({ - phase: 'LOBBY', - dayCount: 0, - timerSeconds: 0, - players: INITIAL_PLAYERS, - logs: [{ id: '1', timestamp: '12:00:00', message: 'System initialized. Waiting for connection...', type: 'info' }], - }); - - // Simulation Logic (Simulating Real-time updates) - useEffect(() => { - if (!isAuthenticated) return; - - // Simulate WebSocket heartbeat / polling - const interval = setInterval(() => { - setGameState(prev => { - // Decrease timer if active - let newTimer = prev.timerSeconds; - let newPhase = prev.phase; - - if (prev.phase !== 'LOBBY' && prev.phase !== 'GAME_OVER' && prev.timerSeconds > 0) { - newTimer -= 1; - } - - return { - ...prev, - timerSeconds: newTimer, - phase: newPhase - }; - }); - }, 1000); - - return () => clearInterval(interval); - }, [isAuthenticated]); - - // Auth Simulation - const handleLogin = () => { - setTimeout(() => { - setIsAuthenticated(true); - addLog('Admin logged in via Discord OAuth simulation.'); - }, 800); - }; - - const handleAction = (playerId: string, actionType: string) => { - // Simulate API call to Java Bot - addLog(`Admin executed command: /${actionType} on ${playerId}`); - - setGameState(prev => { - const updatedPlayers = prev.players.map(p => { - if (p.id === playerId) { - if (actionType === 'kill') return { ...p, isAlive: false }; - if (actionType === 'revive') return { ...p, isAlive: true }; - if (actionType === 'toggle-jin') return { ...p, isJinBaoBao: !p.isJinBaoBao }; - } - return p; - }); - return { ...prev, players: updatedPlayers }; - }); - }; - - const handleGlobalAction = (action: string) => { - addLog(`Admin executed global command: /${action}`); - - if (action === 'start_game') { - setGameState(prev => ({ - ...prev, - phase: 'NIGHT', - dayCount: 1, - timerSeconds: 30, - logs: [...prev.logs, { id: Date.now().toString(), timestamp: new Date().toLocaleTimeString(), message: 'Game Started!', 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('Game timer paused by Admin.'); - } else if (action === 'reset') { - setGameState(prev => ({ - ...prev, - phase: 'LOBBY', - dayCount: 0, - timerSeconds: 0, - players: INITIAL_PLAYERS, - logs: [...prev.logs, { id: Date.now().toString(), timestamp: new Date().toLocaleTimeString(), message: 'Game Reset.', type: 'alert' }] - })); - } else if (action === 'broadcast_role') { - addLog('Broadcasting roles to all players via DM...'); - } else if (action === 'random_assign') { - addLog('Randomizing roles...'); - } - }; - - const addLog = (msg: string) => { - setGameState(prev => ({ - ...prev, - logs: [{ - id: Date.now().toString(), - timestamp: new Date().toLocaleTimeString(), - message: msg, - type: 'info' as const - }, ...prev.logs].slice(0, 50) - })); - }; - - if (!isAuthenticated) { - return ; - } - - return ( -
- setIsAuthenticated(false)} onShowGuide={() => setShowIntegrationGuide(true)} /> - -
- - -
-
- - {/* Left: Players Grid */} -
-
-

- - Players ({gameState.players.filter(p => p.isAlive).length} Alive) -

-
- -
- {gameState.players.map(player => ( - - ))} -
- - {/* Quick Actions Bar */} -
-

Global Admin Commands

-
- - - -
-
-
- - {/* Right: Logs & Event Stream */} - setGameState(prev => ({...prev, logs: []}))} - onManualCommand={(cmd) => addLog(`Manual command: ${cmd}`)} - /> - -
-
- - {/* Modals */} - {showIntegrationGuide && setShowIntegrationGuide(false)} />} -
-
- ); -}; - -const root = createRoot(document.getElementById('root')!); -root.render(); \ No newline at end of file diff --git a/src/dashboard/src/App.tsx b/src/dashboard/src/App.tsx new file mode 100644 index 0000000..be06969 --- /dev/null +++ b/src/dashboard/src/App.tsx @@ -0,0 +1,138 @@ +import { useState, useEffect } from 'react'; +import { Users } from 'lucide-react'; +import './index.css'; + +import { GameState, GamePhase } from './types'; +import { INITIAL_PLAYERS } from './mockData'; +import { LoginScreen } from './components/LoginScreen'; +import { Sidebar } from './components/Sidebar'; +import { GameHeader } from './components/GameHeader'; +import { PlayerCard } from './components/PlayerCard'; +import { GameLog } from './components/GameLog'; +import { IntegrationGuide } from './components/IntegrationGuide'; +import { useTranslation } from './lib/i18n'; + +const App = () => { + const { t } = useTranslation(); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [showIntegrationGuide, setShowIntegrationGuide] = useState(false); + const [gameState, setGameState] = useState({ + phase: 'LOBBY', + dayCount: 0, + timerSeconds: 0, + players: INITIAL_PLAYERS, + logs: [{ id: '1', timestamp: '12:00:00', message: t('gameLog.systemInit'), type: 'info' }], + }); + + useEffect(() => { + if (!isAuthenticated) return; + 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); + }, [isAuthenticated]); + + const handleLogin = () => { + setTimeout(() => { + setIsAuthenticated(true); + addLog(t('gameLog.adminLogin')); + }, 800); + }; + + const handleAction = (playerId: string, actionType: string) => { + const player = gameState.players.find(p => p.id === playerId)?.name || playerId; + addLog(t('gameLog.adminCommand', { action: actionType, player })); + setGameState(prev => ({ + ...prev, + players: prev.players.map(p => { + if (p.id === playerId) { + if (actionType === 'kill') return { ...p, isAlive: false }; + if (actionType === 'revive') return { ...p, isAlive: true }; + if (actionType === 'toggle-jin') return { ...p, isJinBaoBao: !p.isJinBaoBao }; + } + return p; + }) + })); + }; + + 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(), 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') { + setGameState(prev => ({ + ...prev, phase: 'LOBBY', dayCount: 0, timerSeconds: 0, players: INITIAL_PLAYERS, + logs: [...prev.logs, { id: Date.now().toString(), timestamp: new Date().toLocaleTimeString(), message: t('gameLog.gameReset'), type: 'alert' }] + })); + } else if (action === 'broadcast_role') { + addLog(t('gameLog.broadcastRoles')); + } else if (action === 'random_assign') { + addLog(t('gameLog.randomizeRoles')); + } + }; + + const addLog = (msg: string) => { + setGameState(prev => ({ + ...prev, + logs: [{ id: Date.now().toString(), timestamp: new Date().toLocaleTimeString(), message: msg, type: 'info' as const }, ...prev.logs].slice(0, 50) + })); + }; + + if (!isAuthenticated) { + return ; + } + + return ( +
+ setIsAuthenticated(false)} onShowGuide={() => setShowIntegrationGuide(true)} /> +
+ +
+
+
+
+

+ + {t('players.title')} ({gameState.players.filter(p => p.isAlive).length} {t('players.alive')}) +

+
+
+ {gameState.players.map(player => ())} +
+
+

{t('globalCommands.title')}

+
+ + + +
+
+
+ setGameState(prev => ({ ...prev, logs: [] }))} onManualCommand={(cmd) => addLog(t('gameLog.manualCommand', { cmd }))} /> +
+
+ {showIntegrationGuide && setShowIntegrationGuide(false)} />} +
+
+ ); +}; + +export default App; \ No newline at end of file diff --git a/src/dashboard/src/components/GameHeader.tsx b/src/dashboard/src/components/GameHeader.tsx new file mode 100644 index 0000000..46f2ecc --- /dev/null +++ b/src/dashboard/src/components/GameHeader.tsx @@ -0,0 +1,63 @@ +import { Sun, Moon, Play, Pause, SkipForward } from 'lucide-react'; +import { GamePhase } from '../types'; +import { useTranslation } from '../lib/i18n'; + +interface GameHeaderProps { + phase: GamePhase; + dayCount: number; + timerSeconds: number; + onGlobalAction: (action: string) => void; +} + +export const GameHeader: React.FC = ({ phase, dayCount, timerSeconds, onGlobalAction }) => { + 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"; + + return ( +
+
+
+ {t('gameHeader.currentPhase')} +
+ {phase === 'DAY' ? : } + {t(`phases.${phase}`)} {dayCount > 0 && `#${dayCount}`} +
+
+ +
+ +
+ {t('gameHeader.timer')} +
+ {Math.floor(timerSeconds / 60)}:{String(timerSeconds % 60).padStart(2, '0')} +
+
+
+ +
+ {phase === 'LOBBY' ? ( + + ) : ( + <> + + + + )} +
+
+ ); +}; diff --git a/src/dashboard/src/components/GameLog.tsx b/src/dashboard/src/components/GameLog.tsx new file mode 100644 index 0000000..23b2aa3 --- /dev/null +++ b/src/dashboard/src/components/GameLog.tsx @@ -0,0 +1,55 @@ +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; +} + +export const GameLog: React.FC = ({ logs, onClear, onManualCommand }) => { + 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"; + + return ( +
+
+

+ {t('gameLog.title')} +

+ +
+ +
+ {logs.map(log => ( +
+ {log.timestamp} +
+ {log.type === 'alert' && } + {log.message} +
+
+ ))} +
+ + {/* Console Input */} +
+ { + if (e.key === 'Enter') { + onManualCommand(e.currentTarget.value); + e.currentTarget.value = ''; + } + }} + /> +
+
+ ); +}; diff --git a/src/dashboard/components/IntegrationGuide.tsx b/src/dashboard/src/components/IntegrationGuide.tsx similarity index 100% rename from src/dashboard/components/IntegrationGuide.tsx rename to src/dashboard/src/components/IntegrationGuide.tsx diff --git a/src/dashboard/components/LoginScreen.tsx b/src/dashboard/src/components/LoginScreen.tsx similarity index 62% rename from src/dashboard/components/LoginScreen.tsx rename to src/dashboard/src/components/LoginScreen.tsx index c128133..1c55013 100644 --- a/src/dashboard/components/LoginScreen.tsx +++ b/src/dashboard/src/components/LoginScreen.tsx @@ -1,41 +1,42 @@ - -import React from 'react'; import { Moon } from 'lucide-react'; +import { useTranslation } from '../lib/i18n'; interface LoginScreenProps { onLogin: () => void; } export const LoginScreen: React.FC = ({ onLogin }) => { + const { t } = useTranslation(); + return ( -
+
{/* Background Effects */}
-
-
+
+
-
+
-

Werewolf Helper

-

Admin Dashboard & Game Manager

+

{t('login.title')}

+

{t('login.subtitle')}

-
- Restricted to Admins of the Werewolf Server + {t('login.restriction')}
diff --git a/src/dashboard/src/components/PlayerCard.tsx b/src/dashboard/src/components/PlayerCard.tsx new file mode 100644 index 0000000..e9808de --- /dev/null +++ b/src/dashboard/src/components/PlayerCard.tsx @@ -0,0 +1,84 @@ +import { BadgeAlert, HeartPulse, Shield, Skull, MicOff, Settings } from 'lucide-react'; +import { Player } from '../types'; +import { useTranslation } from '../lib/i18n'; + +interface PlayerCardProps { + player: Player; + onAction: (id: string, action: string) => void; +} + +export const PlayerCard: React.FC = ({ player, onAction }) => { + 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"; + + return ( +
+
+ {/* Header */} +
+
+
+ {player.name} + {player.isSheriff && ( +
+ +
+ )} + {player.isJinBaoBao && ( +
+ +
+ )} +
+
+

{player.name}

+
+ + {t(`roles.${player.role}`)} + + {!player.isAlive && {t('players.dead')}} +
+
+
+
+ {player.isProtected &&
} + {player.isPoisoned &&
} + {player.isSilenced &&
} +
+
+ + {/* Actions (Admin) */} +
+ {player.isAlive ? ( + + ) : ( + + )} + +
+
+
+ ); +}; diff --git a/src/dashboard/components/RoleIcon.tsx b/src/dashboard/src/components/RoleIcon.tsx similarity index 100% rename from src/dashboard/components/RoleIcon.tsx rename to src/dashboard/src/components/RoleIcon.tsx diff --git a/src/dashboard/src/components/Sidebar.tsx b/src/dashboard/src/components/Sidebar.tsx new file mode 100644 index 0000000..627d90a --- /dev/null +++ b/src/dashboard/src/components/Sidebar.tsx @@ -0,0 +1,58 @@ +import { Moon, Activity, Settings, Code, LogOut } from 'lucide-react'; +import { useTranslation } from '../lib/i18n'; +import { ThemeToggle } from './ThemeToggle'; + +interface SidebarProps { + onLogout: () => void; + onShowGuide: () => void; +} + +export const Sidebar: React.FC = ({ onLogout, onShowGuide }) => { + const { t } = useTranslation(); + + return ( + + ); +}; diff --git a/src/dashboard/src/components/ThemeToggle.tsx b/src/dashboard/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..15f0afe --- /dev/null +++ b/src/dashboard/src/components/ThemeToggle.tsx @@ -0,0 +1,20 @@ +import { Sun, Moon } from 'lucide-react'; +import { useTheme } from '../lib/ThemeProvider'; + +export const ThemeToggle: React.FC = () => { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +}; diff --git a/src/dashboard/index.css b/src/dashboard/src/index.css similarity index 53% rename from src/dashboard/index.css rename to src/dashboard/src/index.css index 49e37d8..005070a 100644 --- a/src/dashboard/index.css +++ b/src/dashboard/src/index.css @@ -2,6 +2,16 @@ @tailwind components; @tailwind utilities; +@layer base { + * { + transition: background-color 0.2s ease, border-color 0.2s ease; + } + + body { + @apply bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100; + } +} + /* Custom Scrollbar */ .scrollbar-hide::-webkit-scrollbar { display: none; diff --git a/src/dashboard/src/lib/ThemeProvider.tsx b/src/dashboard/src/lib/ThemeProvider.tsx new file mode 100644 index 0000000..0deff4c --- /dev/null +++ b/src/dashboard/src/lib/ThemeProvider.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within ThemeProvider'); + } + return context; +}; + +interface ThemeProviderProps { + children: ReactNode; +} + +export const ThemeProvider: React.FC = ({ children }) => { + const [theme, setTheme] = useState(() => { + // Check localStorage first + const stored = localStorage.getItem('theme') as Theme | null; + if (stored === 'light' || stored === 'dark') { + return stored; + } + + // Fall back to system preference + if (window.matchMedia('(prefers-color-scheme: light)').matches) { + return 'light'; + } + + return 'dark'; + }); + + useEffect(() => { + const root = document.documentElement; + + if (theme === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + + localStorage.setItem('theme', theme); + }, [theme]); + + const toggleTheme = () => { + setTheme(prev => prev === 'light' ? 'dark' : 'light'); + }; + + return ( + + {children} + + ); +}; diff --git a/src/dashboard/src/lib/i18n.ts b/src/dashboard/src/lib/i18n.ts new file mode 100644 index 0000000..d46590c --- /dev/null +++ b/src/dashboard/src/lib/i18n.ts @@ -0,0 +1,38 @@ +import zhTW from '../locales/zh-TW.json'; + +type TranslationKey = string; +type Translations = typeof zhTW; + +// Simple i18n without external dependencies +const translations: Translations = zhTW; + +export const useTranslation = () => { + const t = (key: TranslationKey, params?: Record): string => { + const keys = key.split('.'); + let value: any = translations; + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = value[k]; + } else { + return key; // Return key if translation not found + } + } + + if (typeof value !== 'string') { + return key; + } + + // Replace parameters in the translation string + if (params) { + return Object.entries(params).reduce( + (str, [key, val]) => str.replace(`{${key}}`, val), + value + ); + } + + return value; + }; + + return { t }; +}; diff --git a/src/dashboard/src/locales/zh-TW.json b/src/dashboard/src/locales/zh-TW.json new file mode 100644 index 0000000..0cbf941 --- /dev/null +++ b/src/dashboard/src/locales/zh-TW.json @@ -0,0 +1,101 @@ +{ + "app": { + "title": "狼人殺助手", + "subtitle": "管理員儀表板與遊戲管理器" + }, + "login": { + "title": "狼人殺助手", + "subtitle": "管理員儀表板與遊戲管理器", + "loginButton": "使用 Discord 登入", + "restriction": "僅限狼人伺服器管理員" + }, + "sidebar": { + "dashboard": "儀表板", + "gameSettings": "遊戲設定", + "integrationGuide": "整合指南", + "botConnected": "機器人已連接", + "signOut": "登出" + }, + "gameHeader": { + "currentPhase": "當前階段", + "timer": "計時器", + "startGame": "開始遊戲", + "nextPhase": "下一階段" + }, + "phases": { + "LOBBY": "大廳", + "NIGHT": "夜晚", + "DAY": "白天", + "VOTING": "投票", + "GAME_OVER": "遊戲結束" + }, + "players": { + "title": "玩家", + "alive": "存活", + "dead": "死亡", + "kill": "殺死", + "revive": "復活", + "edit": "編輯" + }, + "roles": { + "WEREWOLF": "狼人", + "VILLAGER": "村民", + "SEER": "預言家", + "WITCH": "女巫", + "HUNTER": "獵人", + "GUARD": "守衛" + }, + "status": { + "sheriff": "警長", + "jinBaoBao": "金寶寶", + "protected": "已保護", + "poisoned": "已中毒", + "silenced": "已禁言" + }, + "gameLog": { + "title": "遊戲日誌", + "clear": "清除", + "placeholder": "輸入手動命令...", + "systemInit": "系統已初始化。等待連接中...", + "adminLogin": "管理員透過 Discord OAuth 模擬登入。", + "gameStarted": "遊戲已開始!", + "gamePaused": "遊戲計時器已被管理員暫停。", + "gameReset": "遊戲已重置。", + "broadcastRoles": "正在透過私訊向所有玩家廣播角色...", + "randomizeRoles": "正在隨機分配角色...", + "adminCommand": "管理員執行命令:/{action} 對 {player}", + "adminGlobalCommand": "管理員執行全域命令:/{action}", + "manualCommand": "手動命令:{cmd}" + }, + "globalCommands": { + "title": "全域管理員命令", + "broadcastRole": "廣播角色(私訊)", + "randomAssign": "隨機分配角色", + "forceReset": "強制重置" + }, + "integrationGuide": { + "title": "整合指南", + "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": "深色模式" + } +} \ No newline at end of file diff --git a/src/dashboard/src/main.tsx b/src/dashboard/src/main.tsx new file mode 100644 index 0000000..2b67615 --- /dev/null +++ b/src/dashboard/src/main.tsx @@ -0,0 +1,11 @@ +import { createRoot } from 'react-dom/client'; +import { ThemeProvider } from './lib/ThemeProvider'; +import App from './App'; +import './index.css'; + +const root = createRoot(document.getElementById('root')!); +root.render( + + + +); diff --git a/src/dashboard/mockData.ts b/src/dashboard/src/mockData.ts similarity index 100% rename from src/dashboard/mockData.ts rename to src/dashboard/src/mockData.ts diff --git a/src/dashboard/types.ts b/src/dashboard/src/types.ts similarity index 100% rename from src/dashboard/types.ts rename to src/dashboard/src/types.ts diff --git a/src/dashboard/tailwind.config.js b/src/dashboard/tailwind.config.js index 0341812..73324ed 100644 --- a/src/dashboard/tailwind.config.js +++ b/src/dashboard/tailwind.config.js @@ -2,9 +2,9 @@ export default { content: [ "./index.html", - "./components/**/*.{js,ts,jsx,tsx}", - "./*.{ts,tsx}", + "./src/**/*.{js,ts,jsx,tsx}", ], + darkMode: 'class', theme: { extend: {}, }, From 49afaec926f7216f7f51ad6730cee41fe790b296 Mon Sep 17 00:00:00 2001 From: RobotHanzo <36107150+RobotHanzo@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:44:31 +0800 Subject: [PATCH 3/5] stage 1 dashboard done --- .idea/compiler.xml | 2 +- .idea/copilot.data.migration.ask.xml | 6 + .idea/gradle.xml | 3 +- .idea/misc.xml | 2 +- build.gradle.kts | 24 +- build_log.txt | 39 + src/dashboard/README.md | 30 + src/dashboard/package.json | 5 +- src/dashboard/src/App.tsx | 669 ++++++++- src/dashboard/src/components/AccessDenied.tsx | 42 + src/dashboard/src/components/AuthCallback.tsx | 44 + .../src/components/DeathConfirmModal.tsx | 99 ++ src/dashboard/src/components/GameHeader.tsx | 70 +- src/dashboard/src/components/GameLog.tsx | 86 +- .../src/components/GameSettingsPage.tsx | 403 ++++++ .../src/components/IntegrationGuide.tsx | 134 -- src/dashboard/src/components/PlayerCard.tsx | 174 ++- .../src/components/PlayerEditModal.tsx | 206 +++ .../src/components/PlayerSelectModal.tsx | 87 ++ .../src/components/ProgressOverlay.tsx | 142 ++ .../src/components/ServerSelector.tsx | 149 ++ .../src/components/SettingsModal.tsx | 117 ++ src/dashboard/src/components/Sidebar.tsx | 155 ++- .../src/components/SpectatorView.tsx | 220 +++ .../src/components/SpeechManager.tsx | 371 +++++ src/dashboard/src/components/ThemeToggle.tsx | 4 +- .../src/components/TimerControlModal.tsx | 90 ++ src/dashboard/src/contexts/AuthContext.tsx | 95 ++ src/dashboard/src/index.css | 162 ++- src/dashboard/src/lib/api.ts | 218 +++ src/dashboard/src/lib/websocket.ts | 180 +++ src/dashboard/src/locales/zh-TW.json | 281 +++- src/dashboard/src/main.tsx | 8 +- src/dashboard/src/mockData.ts | 3 +- src/dashboard/src/types.ts | 78 +- src/dashboard/vite.config.ts | 13 + src/dashboard/yarn.lock | 25 + .../robothanzo/werewolf/WerewolfHelper.java | 13 + .../robothanzo/werewolf/commands/Player.java | 314 +++-- .../robothanzo/werewolf/commands/Poll.java | 138 +- .../robothanzo/werewolf/commands/Server.java | 21 +- .../robothanzo/werewolf/commands/Speech.java | 156 ++- .../werewolf/database/Database.java | 39 +- .../database/documents/AuthSession.java | 37 + .../werewolf/database/documents/LogType.java | 66 + .../werewolf/database/documents/Session.java | 103 +- .../werewolf/listeners/ButtonListener.java | 2 +- .../werewolf/listeners/MessageListener.java | 28 +- .../werewolf/server/SessionAPI.java | 1093 +++++++++++++++ .../robothanzo/werewolf/server/WebServer.java | 1205 +++++++++++++++++ .../werewolf/utils/DiscordActionRunner.java | 75 + .../werewolf/utils/SetupHelper.java | 10 +- 52 files changed, 7157 insertions(+), 579 deletions(-) create mode 100644 .idea/copilot.data.migration.ask.xml create mode 100644 build_log.txt create mode 100644 src/dashboard/src/components/AccessDenied.tsx create mode 100644 src/dashboard/src/components/AuthCallback.tsx create mode 100644 src/dashboard/src/components/DeathConfirmModal.tsx create mode 100644 src/dashboard/src/components/GameSettingsPage.tsx delete mode 100644 src/dashboard/src/components/IntegrationGuide.tsx create mode 100644 src/dashboard/src/components/PlayerEditModal.tsx create mode 100644 src/dashboard/src/components/PlayerSelectModal.tsx create mode 100644 src/dashboard/src/components/ProgressOverlay.tsx create mode 100644 src/dashboard/src/components/ServerSelector.tsx create mode 100644 src/dashboard/src/components/SettingsModal.tsx create mode 100644 src/dashboard/src/components/SpectatorView.tsx create mode 100644 src/dashboard/src/components/SpeechManager.tsx create mode 100644 src/dashboard/src/components/TimerControlModal.tsx create mode 100644 src/dashboard/src/contexts/AuthContext.tsx create mode 100644 src/dashboard/src/lib/api.ts create mode 100644 src/dashboard/src/lib/websocket.ts create mode 100644 src/main/java/dev/robothanzo/werewolf/database/documents/AuthSession.java create mode 100644 src/main/java/dev/robothanzo/werewolf/database/documents/LogType.java create mode 100644 src/main/java/dev/robothanzo/werewolf/server/SessionAPI.java create mode 100644 src/main/java/dev/robothanzo/werewolf/server/WebServer.java create mode 100644 src/main/java/dev/robothanzo/werewolf/utils/DiscordActionRunner.java diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 03c5b73..899ae05 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -5,7 +5,7 @@ - + diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 0000000..7ef04e2 --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 6a845bf..89022a7 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,9 +4,7 @@ + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 14b8428..0099a5c 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -18,7 +18,7 @@
+ ); +}; + +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 ( +
+
Loading...
+
+ ); + } + + 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')} +
+ + +
+
+ ); +}; 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" + /> + +
+ +
+ + +
+
+
+
+ ); +}; 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' ? ( - - ) : ( - <> - + {!readonly && ( + phase === 'LOBBY' ? ( - + ) : ( + <> + + + + ) )}
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')}

-
@@ -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')}

+
+ + + +
+
+ + {/* Voice & Timer */} +
+

{t('globalCommands.voiceTimer')}

+
+ + + +
+
+ + {/* Admin & Roles */} +
+

{t('globalCommands.adminRoles')}

+
+ + + +
+
+ +
+
+ )}
); }; 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 ? ( + + ) : ( + + )} +
+ )} + +
+
+ +
+
+ {t('settings.doubleIdentities')} + + {t('settings.doubleIdentitiesDesc')} + +
+
+ {(saving || justSaved) && ( +
+ {saving ? ( + + ) : ( + + )} +
+ )} + +
+
+
+ + {/* Player Count Settings */} +
+

+ {t('settings.playerCount')} +

+
+
+ + 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')} +

+
+ +
+
+ + {/* Roles Settings */} +
+

+
+ {t('roles.title')} + {t('messages.totalCount')}: {roles.length} +
+ +

+ + {/* 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 => ( + +
+ +
+ + {/* Roles List */} +
+ {Object.entries(roleCounts).sort((a, b) => b[1] - a[1]).map(([role, count]) => ( +
+
+
+ +
+ {role} +
+
+ + + {count} + + +
+
+ ))} + + {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 - -
-
-
-                {javaCode}
-              
-
-
- -
-
-

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. -

-
-
-
- -
- -
-
-
- ); -}; 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.name} + {player.avatar ? ( + {player.name} + ) : ( +
+ +
+ )} {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 && ( + + )} {!player.isAlive && {t('players.dead')}}
-
+
{player.isProtected &&
} {player.isPoisoned &&
} {player.isSilenced &&
}
+
- {/* Actions (Admin) */} -
+ {/* Actions (Admin) */} + {!readonly && ( +
{player.isAlive ? ( ) : (
-
+ )}
); }; 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)} +
+
+ +
+
+ + +
+ + {isDoubleIdentity && ( +
+ + +
+ )} + + {isDoubleIdentity && ( +
+
+ + +
+
+ )} + + +
+
+ + {/* Police Badge Transfer Section */} + {player.isSheriff ? ( +
+
+ + {t('status.sheriff')} +
+
+

+ {t('players.transferPoliceDescription', 'Transfer the police badge to another alive player.')} +

+ +
+ + +
+
+
+ ) : 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 => ( + + )) + )} +
+
+
+ ); +}; 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') && ( +
+ +
+ )} +
+
+ ); +}; 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}

+ +
+ )} + + {!loading && !error && sessions.length === 0 && ( +
+ +

{t('serverSelector.noSessions')}

+

+ {t('serverSelector.noSessionsHint')} +

+
+ )} + + {!loading && !error && sessions.length > 0 && ( +
+ {sessions.map((session) => ( + + ))} +
+ )} + +
+ +
+
+
+ ); +}; 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 */} +
+ + 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 */} +
+ + + {testResult === 'success' && ( +
+ + {t('settingsModal.connectionSuccess')} +
+ )} + + {testResult === 'error' && ( +
+ + {t('settingsModal.connectionFailed')} +
+ )} +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; 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 (
- 狼人助手 + {t('app.title').split('助手')[0]}助手
-
+
+ {/* User Profile */} + {user && ( +
+ {user.username} +
+

+ {user.username} +

+ {user.role === 'JUDGE' ? ( + + ) : ( + + {t(`userRoles.${user.role}`) || user.role} + + )} +
+
+ )} + + {/* Connection Status */}
-
- {t('sidebar.botConnected')} +
+ + {isConnected ? t('sidebar.botConnected') : t('sidebar.botDisconnected')} +
+ {/* Action Buttons */}
- +
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 && ( +
+ + +
+ )} +
+ ); + } + + // 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')}
+ + +
+ )} +
+ ) : ( // ... 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} +
+ +
+
+ +
+

{player.name}

+ {t('speechManager.speaking')} +
+ +
+ + + {Math.floor(timeLeft / 60).toString().padStart(2, '0')}:{String(timeLeft % 60).padStart(2, '0')} + +
+ + {!readonly && ( +
+ + +
+ )} +
+
+); 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 ( +
+ +
+ {/* Custom Input */} +
+
+ + 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" + /> +
+ : +
+ + 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 => ( + + ))} +
+ + +
+
+
+ ); +}; 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 && ( +
+ +
+ )} + + )} + {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 ( -
-
Loading...
-
- ); - } + // Show loading while checking auth + if (loading) { + return ( +
+
Loading...
+
+ ); + } - 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" /> -
@@ -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 ( -
-
-
- {t('gameHeader.currentPhase')} -
- {phase === 'DAY' ? : } - {t(`phases.${phase}`)} {dayCount > 0 && `#${dayCount}`} -
-
+ return ( +
+
+
+ {t('gameHeader.currentPhase')} +
+ {phase === 'DAY' ? : + } + {t(`phases.${phase}`)} {dayCount > 0 && `#${dayCount}`} +
+
- {currentSpeaker && ( - <> -
- + {currentSpeaker && ( + <> +
+ - {t('messages.speaking')} + {t('messages.speaking')} -
- {currentSpeaker.name} - {speech?.endTime && ( - +
+ {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')}`; + const seconds = Math.max(0, Math.ceil((speech.endTime - Date.now()) / 1000)); + return `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; })()} + )} +
+ + )} -
- - - )} -
+
-
- {t('gameHeader.timer')} -
- {Math.floor(timerSeconds / 60)}:{String(timerSeconds % 60).padStart(2, '0')} -
-
-
+
+ {t('gameHeader.timer')} +
+ {Math.floor(timerSeconds / 60)}:{String(timerSeconds % 60).padStart(2, '0')} +
+
+
-
- {!readonly && ( - phase === 'LOBBY' ? ( - - ) : ( - <> - - - - ) - )} -
-
- ); +
+ {!readonly && ( + phase === 'LOBBY' ? ( + + ) : ( + <> + + + + ) + )} +
+
+ ); }; 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')}

-
- - - -
-
+ {/* Game Flow */} +
+

{t('globalCommands.gameFlow')}

+
+ + + +
+
- {/* Voice & Timer */} -
-

{t('globalCommands.voiceTimer')}

-
- - - -
-
+ {/* Voice & Timer */} +
+

{t('globalCommands.voiceTimer')}

+
+ + + +
+
- {/* Admin & Roles */} -
-

{t('globalCommands.adminRoles')}

-
- - - -
-
+ {/* Admin & Roles */} +
+

{t('globalCommands.adminRoles')}

+
+ + + +
+
-
+
+
+ )}
- )} -
- ); + ); }; 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 ? ( - + ) : ( - + )}
)} -
- {t('settings.doubleIdentities')} + {t('settings.doubleIdentities')} {t('settings.doubleIdentitiesDesc')} @@ -249,13 +212,14 @@ export const GameSettingsPage: React.FC = () => { {(saving || justSaved) && (
{saving ? ( - + ) : ( - + )}
)} -
@@ -305,13 +270,14 @@ export const GameSettingsPage: React.FC = () => {

{t('roles.title')} - {t('messages.totalCount')}: {roles.length} + {t('messages.totalCount')}: {roles.length}

@@ -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]) => ( -
+
-
- +
{role}
@@ -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}
))} {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.restriction')} -
+
+ +
+ {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.name} + ) : ( +
+ +
+ )} + {player.isSheriff && ( +
+ +
+ )} + {player.isJinBaoBao && ( +
+ +
+ )} - return ( -
-
- {/* Header */} -
-
-
- {player.avatar ? ( - {player.name} - ) : ( -
- -
- )} - {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 && ( + + )} + {!player.isAlive && {t('players.dead')}} +
+
+
+
+ {player.isProtected &&
} + {player.isPoisoned &&
} + {player.isSilenced &&
} +
- {!readonly && player.roles.length > 1 && !player.rolePositionLocked && ( - - )} - {!player.isAlive && {t('players.dead')}} -
-
-
- {player.isProtected &&
} - {player.isPoisoned &&
} - {player.isSilenced &&
} -
-
-
- {/* Actions (Admin) */} - {!readonly && ( -
- {player.isAlive ? ( - - ) : ( - - )} - + {/* Actions (Admin) */} + {!readonly && ( +
+ {player.isAlive ? ( + + ) : ( + + )} + +
+ )}
- )} -
- ); + ); }; 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)}
-
+
- + = ({ player, allPla {isDoubleIdentity && (
- +
@@ -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 ( -
-
-
+
+
+

{title}

-
- + = ({ title, pla filteredPlayers.map(p => ( )) )} diff --git a/src/dashboard/src/components/ProgressOverlay.tsx b/src/dashboard/src/components/ProgressOverlay.tsx index f07cf59..dbd8b74 100644 --- a/src/dashboard/src/components/ProgressOverlay.tsx +++ b/src/dashboard/src/components/ProgressOverlay.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Loader2, CheckCircle2, XCircle } from 'lucide-react'; -import { useTranslation } from '../lib/i18n'; +import React, {useEffect, useRef, useState} from 'react'; +import {CheckCircle2, Loader2, XCircle} from 'lucide-react'; +import {useTranslation} from '../lib/i18n'; interface ProgressOverlayProps { isVisible: boolean; @@ -11,26 +11,28 @@ interface ProgressOverlayProps { status: 'processing' | 'success' | 'error'; error?: string; progress?: number; + children?: React.ReactNode; } export const ProgressOverlay: React.FC = ({ - isVisible, - title, - logs, - onComplete, - autoCloseDelay = 1500, - status, - error, - progress -}) => { - const { t } = useTranslation(); + isVisible, + title, + logs, + onComplete, + autoCloseDelay = 1500, + status, + error, + progress, + children + }) => { + 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' }); + logEndRef.current?.scrollIntoView({behavior: 'smooth'}); }, [logs]); // Handle animation on mount/unmount @@ -64,16 +66,19 @@ export const ProgressOverlay: React.FC = ({ if (!shouldRender) return null; return ( -
-
{/* Header */} -
- {status === 'processing' && } - {status === 'success' && } - {status === 'error' && } +
+ {status === 'processing' && } + {status === 'success' && } + {status === 'error' && }

@@ -83,54 +88,66 @@ export const ProgressOverlay: React.FC = ({

{t('progressOverlay.processing')} {progress !== undefined ? `${Math.round(progress)}%` : ''}

{progress !== undefined && ( -
+
)}
)} - {status === 'success' &&

{t('progressOverlay.complete')}

} - {status === 'error' &&

{error || t('progressOverlay.unknownError')}

} + {status === 'success' && +

{t('progressOverlay.complete')}

} + {status === 'error' && +

{error || t('progressOverlay.unknownError')}

}
- {/* Log Terminal */} -
-
- {logs.map((log, index) => ( -
- {'>'} - - {log} - -
- ))} - {status === 'processing' && ( -
- {'>'} - _ + {/* Content Area */} +
+ {children ? ( +
+ {children} +
+ ) : ( +
+
+ {logs.map((log, index) => ( +
+ {'>'} + + {log} + +
+ ))} + {status === 'processing' && ( +
+ {'>'} + _ +
+ )} +
- )} -
-
+
+ )}
{/* Footer - Show OK button for success or error */} {(status === 'success' || status === 'error') && ( -
+
diff --git a/src/dashboard/src/components/RoleIcon.tsx b/src/dashboard/src/components/RoleIcon.tsx index a5e891b..97ecb49 100644 --- a/src/dashboard/src/components/RoleIcon.tsx +++ b/src/dashboard/src/components/RoleIcon.tsx @@ -1,16 +1,22 @@ - import React from 'react'; -import { Moon, Eye, Zap, Swords, Shield, Users } from 'lucide-react'; -import { Role } from '../types'; +import {Eye, Moon, Shield, Swords, Users, Zap} from 'lucide-react'; +import {Role} from '../types'; -export const RoleIcon = ({ role }: { role: Role }) => { - switch (role) { - case 'WEREWOLF': return ; - case 'SEER': return ; - case 'WITCH': return ; - case 'HUNTER': return ; - case 'GUARD': return ; - case 'VILLAGER': return ; - default: return ; - } +export const RoleIcon = ({role}: { role: Role }) => { + switch (role) { + case 'WEREWOLF': + return ; + case 'SEER': + return ; + case 'WITCH': + return ; + case 'HUNTER': + return ; + case 'GUARD': + return ; + case 'VILLAGER': + return ; + default: + return ; + } }; diff --git a/src/dashboard/src/components/ServerSelector.tsx b/src/dashboard/src/components/ServerSelector.tsx index 366f870..5cca9bb 100644 --- a/src/dashboard/src/components/ServerSelector.tsx +++ b/src/dashboard/src/components/ServerSelector.tsx @@ -1,13 +1,14 @@ -import { useState, useEffect } from 'react'; -import { Server, Users, Loader2 } from 'lucide-react'; -import { useTranslation } from '../lib/i18n'; -import { api } from '../lib/api'; +import {useEffect, useState} from 'react'; +import {Loader2, Server, Users} from 'lucide-react'; +import {useTranslation} from '../lib/i18n'; +import {api} from '../lib/api'; interface Session { guildId: string; guildName: string; guildIcon?: string; - players: any[]; + players?: any[]; + playerCount?: number; } interface ServerSelectorProps { @@ -15,8 +16,8 @@ interface ServerSelectorProps { onBack: () => void; } -export const ServerSelector: React.FC = ({ onSelectServer, onBack }) => { - const { t } = useTranslation(); +export const ServerSelector: React.FC = ({onSelectServer, onBack}) => { + const {t} = useTranslation(); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -45,17 +46,22 @@ export const ServerSelector: React.FC = ({ onSelectServer, }; return ( -
+
{/* Background Effects */}
-
-
+
+
-
+
-
- +
+

{t('serverSelector.title')}

{t('serverSelector.subtitle')}

@@ -63,13 +69,14 @@ export const ServerSelector: React.FC = ({ onSelectServer, {loading && (
- +

{t('serverSelector.loading')}

)} {error && ( -
+

{error}

- + - {session.players?.length || 0} {t('serverSelector.players')} + {session.playerCount !== undefined ? session.playerCount : (session.players?.length || 0)} {t('serverSelector.players')}
@@ -128,7 +136,8 @@ export const ServerSelector: React.FC = ({ onSelectServer, viewBox="0 0 24 24" stroke="currentColor" > - + ))} diff --git a/src/dashboard/src/components/SessionExpiredModal.tsx b/src/dashboard/src/components/SessionExpiredModal.tsx new file mode 100644 index 0000000..9760b1a --- /dev/null +++ b/src/dashboard/src/components/SessionExpiredModal.tsx @@ -0,0 +1,43 @@ +import {LogOut} from 'lucide-react'; +import {useTranslation} from '../lib/i18n'; + +interface SessionExpiredModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const SessionExpiredModal = ({isOpen, onClose}: SessionExpiredModalProps) => { + const {t} = useTranslation(); + + if (!isOpen) return null; + + return ( +
+
+
+
+ +
+ +

+ {t('auth.sessionExpiredTitle') || "Session Expired"} +

+ +

+ {t('auth.sessionExpiredMessage') || "Your session has expired. Please log in again."} +

+ + +
+
+
+ ); +}; diff --git a/src/dashboard/src/components/SettingsModal.tsx b/src/dashboard/src/components/SettingsModal.tsx index 6db875b..b27a2bd 100644 --- a/src/dashboard/src/components/SettingsModal.tsx +++ b/src/dashboard/src/components/SettingsModal.tsx @@ -1,14 +1,14 @@ -import { useState } from 'react'; -import { X, Check, AlertCircle, Wifi } from 'lucide-react'; -import { useTranslation } from '../lib/i18n'; -import { api } from '../lib/api'; +import {useState} from 'react'; +import {AlertCircle, Check, Wifi, X} from 'lucide-react'; +import {useTranslation} from '../lib/i18n'; +import {api} from '../lib/api'; interface SettingsModalProps { onClose: () => void; } -export const SettingsModal: React.FC = ({ onClose }) => { - const { t } = useTranslation(); +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); @@ -36,7 +36,8 @@ export const SettingsModal: React.FC = ({ onClose }) => { return (
-
+
{/* Header */}

{t('sidebar.gameSettings')}

@@ -44,7 +45,7 @@ export const SettingsModal: React.FC = ({ onClose }) => { onClick={onClose} className="p-2 hover:bg-slate-200 dark:hover:bg-slate-800 rounded-lg transition-colors" > - +
@@ -76,20 +77,20 @@ export const SettingsModal: React.FC = ({ onClose }) => { disabled={testing || !backendUrl} className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-400 dark:disabled:bg-slate-700 text-white rounded-lg transition-colors disabled:cursor-not-allowed" > - + {testing ? t('settingsModal.testing') : t('settingsModal.testConnection')} {testResult === 'success' && (
- + {t('settingsModal.connectionSuccess')}
)} {testResult === 'error' && (
- + {t('settingsModal.connectionFailed')}
)} @@ -97,7 +98,8 @@ export const SettingsModal: React.FC = ({ onClose }) => {
{/* Footer */} -
+
- - - )} + + -
- {/* User Profile */} - {user && ( -
- {user.username} -
-

- {user.username} -

- {user.role === 'JUDGE' ? ( - - ) : ( - +
+ {/* User Profile */} + {user && ( +
+ {user.username} +
+

+ {user.username} +

+ {user.role === 'JUDGE' ? ( + + ) : ( + {t(`userRoles.${user.role}`) || user.role} - )} -
-
- )} + )} +
+
+ )} - {/* Connection Status */} -
-
- + {/* Connection Status */} +
+
+ {isConnected ? t('sidebar.botConnected') : t('sidebar.botDisconnected')} -
+
- {/* Action Buttons */} -
- - - -
-
- - ); + {/* Action Buttons */} +
+ + + +
+
+ + ); }; diff --git a/src/dashboard/src/components/SpeakerCard.tsx b/src/dashboard/src/components/SpeakerCard.tsx new file mode 100644 index 0000000..e9b8b97 --- /dev/null +++ b/src/dashboard/src/components/SpeakerCard.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import {Clock, Mic, SkipForward, Square} from 'lucide-react'; +import {Player} from '../types'; + +interface SpeakerCardProps { + player: Player; + timeLeft: number; + t: any; + readonly: boolean; + onSkip?: () => void; + onInterrupt?: () => void; +} + +export const SpeakerCard = ({player, timeLeft, t, readonly, onSkip, onInterrupt}: SpeakerCardProps) => ( +
+
+
+
+ {player.name} +
+ +
+
+ +
+

{player.name}

+ {t('speechManager.speaking')} +
+ +
+ + + {Math.floor(timeLeft / 60).toString().padStart(2, '0')}:{String(timeLeft % 60).padStart(2, '0')} + +
+ + {!readonly && ( +
+ + +
+ )} +
+
+); diff --git a/src/dashboard/src/components/SpectatorView.tsx b/src/dashboard/src/components/SpectatorView.tsx index 7f91fab..78549a8 100644 --- a/src/dashboard/src/components/SpectatorView.tsx +++ b/src/dashboard/src/components/SpectatorView.tsx @@ -1,16 +1,16 @@ -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'; +import React, {useMemo} from 'react'; +import {useTranslation} from '../lib/i18n'; +import {Player} from '../types'; +import {HeartPulse, Shield, Skull, Users, Zap} from 'lucide-react'; +import {PlayerCard} from './PlayerCard'; interface SpectatorViewProps { players: Player[]; doubleIdentities: boolean; } -export const SpectatorView: React.FC = ({ players, doubleIdentities }) => { - const { t } = useTranslation(); +export const SpectatorView: React.FC = ({players, doubleIdentities}) => { + const {t} = useTranslation(); const stats = useMemo(() => { let wolves = 0; @@ -86,7 +86,7 @@ export const SpectatorView: React.FC = ({ players, doubleIde {/* Wolves Status */} } + icon={} color="red" current={stats.wolves - stats.deadWolves} total={stats.wolves} @@ -98,7 +98,7 @@ export const SpectatorView: React.FC = ({ players, doubleIde <> } + icon={} color="yellow" current={stats.gods - stats.deadGods} total={stats.gods} @@ -106,7 +106,7 @@ export const SpectatorView: React.FC = ({ players, doubleIde /> } + icon={} color="pink" current={stats.jinBaoBaos - stats.deadJinBaoBaos} total={stats.jinBaoBaos} @@ -117,7 +117,7 @@ export const SpectatorView: React.FC = ({ players, doubleIde <> } + icon={} color="yellow" current={stats.gods - stats.deadGods} total={stats.gods} @@ -125,7 +125,7 @@ export const SpectatorView: React.FC = ({ players, doubleIde /> } + icon={} color="emerald" current={stats.villagers - stats.deadVillagers} total={stats.villagers} @@ -135,7 +135,8 @@ export const SpectatorView: React.FC = ({ players, doubleIde )}
-
+

{t('spectator.winConditions')}

  • {t('spectator.goodWinCondition')}
  • @@ -146,15 +147,17 @@ export const SpectatorView: React.FC = ({ players, doubleIde {/* Read-only Player Grid */}

    - - {t('players.title')} ({players.filter(p => p.isAlive).length} {t('players.alive')}) + + {t('players.title')} ({players.filter(p => p.isAlive).length} {t('players.alive')})

    {players.map(player => ( { }} + onAction={() => { + }} readonly={true} /> ))} @@ -175,19 +178,20 @@ interface FactionCardProps { description: string; } -const FactionCard: React.FC = ({ title, icon, color, current, total, description }) => { - const { t } = useTranslation(); +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' }, + 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 ( -
    +
    @@ -211,7 +215,7 @@ const FactionCard: React.FC = ({ title, icon, color, current,
    diff --git a/src/dashboard/src/components/SpeechManager.tsx b/src/dashboard/src/components/SpeechManager.tsx index 2296b2e..5da553e 100644 --- a/src/dashboard/src/components/SpeechManager.tsx +++ b/src/dashboard/src/components/SpeechManager.tsx @@ -1,8 +1,11 @@ -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'; +import {useEffect, useState} from 'react'; +import {ArrowDown, ArrowUp, Clock, Mic, Play, Shield, Square, UserMinus, UserPlus} from 'lucide-react'; +import {Player, PoliceState, SpeechState} from '../types'; +import {VoteStatus} from './VoteStatus'; +import {api} from '../lib/api'; +import {useTranslation} from '../lib/i18n'; + +import {SpeakerCard} from './SpeakerCard'; interface SpeechManagerProps { speech?: SpeechState; @@ -12,8 +15,8 @@ interface SpeechManagerProps { readonly?: boolean; } -export const SpeechManager = ({ speech, police, players, guildId, readonly = false }: SpeechManagerProps) => { - const { t } = useTranslation(); +export const SpeechManager = ({speech, police, players, guildId, readonly = false}: SpeechManagerProps) => { + const {t} = useTranslation(); const [timeLeft, setTimeLeft] = useState(0); useEffect(() => { @@ -53,7 +56,9 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal // 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); + // Check if police enrollment is ACTIVELY happening (not just if candidates exist) + // Only show police UI when enrollment or unenrollment is allowed OR voting + const isPoliceActive = police && (police.allowEnroll || police.allowUnEnroll || police.state === 'VOTING'); const isActive = isSpeechActive || isPoliceActive; @@ -101,7 +106,7 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal return (
    - +

    {t('sidebar.speechManager')}

    @@ -113,14 +118,14 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal onClick={handleStart} className="flex items-center gap-2 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg shadow-lg transition-transform hover:scale-105" > - + {t('speechManager.startAuto')}

    @@ -129,27 +134,74 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal ); } + // Police Voting Phase + if (police?.state === 'VOTING') { + const stageEndTime = police.stageEndTime; + + return ( +
    +
    + !c.quit)} + totalVoters={players.filter(p => p.isAlive && !police.candidates.some(c => c.id === String(p.id))).length} + endTime={stageEndTime} + players={players} + title={t('vote.policeElection')} + /> +
    +
    + ); + } + // If Police Enrollment is active and no speech is active (or even if it is, show police view if appropriate? usually exclusive) // Assuming police enrollment happens before speech starts. if (isPoliceActive && !isSpeechActive) { + // Unenrollment Timer Logic + const isUnenrollment = police?.state === 'UNENROLLMENT'; + const timerSeconds = police?.stageEndTime ? Math.max(0, Math.ceil((police.stageEndTime - Date.now()) / 1000)) : 0; + + // Force re-render for timer if needed, but App likely triggers updates. + // We can reuse local state or just rely on props updates. + // For smoother countdown we might need a useEffect driven timer like for speech. + return ( -
    +
    - -

    {t('speechManager.policeEnrollment')}

    + +

    + {isUnenrollment ? t('speechManager.policeUnenrollment') : t('speechManager.policeEnrollment')} +

    + {timerSeconds > 0 && ( +
    + + {Math.floor(timerSeconds / 60).toString().padStart(2, '0')}:{String(timerSeconds % 60).padStart(2, '0')} +
    + )}
    -
    +
    - {police?.allowEnroll ? : } - {t('speechManager.allowEnroll')}: {police?.allowEnroll ? 'YES' : 'NO'} + {police?.allowEnroll ? : } + {t('speechManager.allowEnroll')}: {police?.allowEnroll ? 'YES' : 'NO'}
    -
    +
    - {police?.allowUnEnroll ? : } - {t('speechManager.allowUnEnroll')}: {police?.allowUnEnroll ? 'YES' : 'NO'} + {police?.allowUnEnroll ? : } + {t('speechManager.allowUnEnroll')}: {police?.allowUnEnroll ? 'YES' : 'NO'}
    @@ -158,12 +210,15 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal

    {t('speechManager.candidates')}

    {police?.candidates && police.candidates.length > 0 ? (
    - {police.candidates.map(cid => { - const p = players.find(x => x.id === cid); + {police.candidates.map(candidate => { + const p = players.find(x => x.id === candidate.id); return ( -
    - - {p?.name || `Player ${cid}`} +
    + + {p?.name || `Player ${candidate.id}`}
    ); })} @@ -177,11 +232,13 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal } return ( -
    -
    +
    +
    - +

    {t('speechManager.activeSpeech')}

    @@ -190,14 +247,16 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal
    -
    +
    {isPoliceSelecting ? (
    -
    -
    - +
    +
    +

    {t('speechManager.waitingForPolice')}

    {t('speechManager.waitingForPoliceSub')}

    @@ -207,25 +266,26 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal {!readonly && (
    -
    {t('speechManager.judgeOverride')}
    +
    {t('speechManager.judgeOverride')}
    )}
    - ) : ( // ... rest of the speech UI + ) : ( <> {/* Current Speaker Node */} {/* Speaker Area with Swiping Animation */} @@ -236,7 +296,7 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal player={exitingSpeaker} timeLeft={0} t={t} - readonly={true} // Old speaker shouldn't be interactive during exit + readonly={true} />
    )} @@ -256,24 +316,29 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal />
    ) : ( - !exitingSpeaker &&
    {t('speechManager.preparing')}
    + !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} +
    + {voter?.avatar && + } + {voter?.name || voterId}
    ); })} @@ -286,16 +351,22 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal {nextPlayers && nextPlayers.map((player, idx) => (
    {idx > 0 && ( -
    +
    )} -
    +
    - + {idx + 1}
    - - {player.name} + + {player.name}
    {t('speechManager.waiting')} @@ -305,7 +376,8 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal
    {nextPlayers && nextPlayers.length === 0 && currentSpeaker && ( -
    {t('speechManager.noMoreSpeakers')}
    +
    {t('speechManager.noMoreSpeakers')}
    )} )} @@ -314,58 +386,4 @@ export const SpeechManager = ({ speech, police, players, guildId, readonly = fal ); }; -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} -
    - -
    -
    - -
    -

    {player.name}

    - {t('speechManager.speaking')} -
    - -
    - - - {Math.floor(timeLeft / 60).toString().padStart(2, '0')}:{String(timeLeft % 60).padStart(2, '0')} - -
    - {!readonly && ( -
    - - -
    - )} -
    -
    -); diff --git a/src/dashboard/src/components/ThemeToggle.tsx b/src/dashboard/src/components/ThemeToggle.tsx index 3bd7053..a7e6f79 100644 --- a/src/dashboard/src/components/ThemeToggle.tsx +++ b/src/dashboard/src/components/ThemeToggle.tsx @@ -1,10 +1,10 @@ -import { Sun, Moon } from 'lucide-react'; -import { useTheme } from '../lib/ThemeProvider'; -import { useTranslation } from '../lib/i18n'; +import {Moon, Sun} from 'lucide-react'; +import {useTheme} from '../lib/ThemeProvider'; +import {useTranslation} from '../lib/i18n'; export const ThemeToggle: React.FC = () => { - const { theme, toggleTheme } = useTheme(); - const { t } = useTranslation(); + const {theme, toggleTheme} = useTheme(); + const {t} = useTranslation(); return ( ); diff --git a/src/dashboard/src/components/TimerControlModal.tsx b/src/dashboard/src/components/TimerControlModal.tsx index be1b131..3a79765 100644 --- a/src/dashboard/src/components/TimerControlModal.tsx +++ b/src/dashboard/src/components/TimerControlModal.tsx @@ -1,18 +1,18 @@ -import React, { useState } from 'react'; -import { X, Clock, Play } from 'lucide-react'; -import { useTranslation } from '../lib/i18n'; +import React, {useState} from 'react'; +import {Clock, Play, X} from 'lucide-react'; +import {useTranslation} from '../lib/i18n'; interface TimerControlModalProps { onClose: () => void; onStart: (seconds: number) => void; } -export const TimerControlModal: React.FC = ({ onClose, onStart }) => { - const { t } = useTranslation(); +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 presets = [30, 60, 90, 180]; const handleStart = () => { const totalSeconds = (minutes * 60) + seconds; @@ -23,15 +23,19 @@ export const TimerControlModal: React.FC = ({ onClose, o }; return ( -
    -
    -
    +
    +
    +

    - + {t('timer.title') || 'Start Timer'}

    -
    @@ -39,7 +43,8 @@ export const TimerControlModal: React.FC = ({ onClose, o {/* Custom Input */}
    - + = ({ onClose, o
    :
    - + = ({ onClose, o {presets.map(s => (
    diff --git a/src/dashboard/src/components/VoteStatus.tsx b/src/dashboard/src/components/VoteStatus.tsx new file mode 100644 index 0000000..65ccd82 --- /dev/null +++ b/src/dashboard/src/components/VoteStatus.tsx @@ -0,0 +1,135 @@ +import React, {useEffect, useState} from 'react'; +import {Clock} from 'lucide-react'; +import {Player} from '../types'; +import {useTranslation} from '../lib/i18n'; + +interface VoteStatusProps { + candidates: { id: string, voters: string[] }[]; + totalVoters?: number; + endTime?: number; + players: Player[]; + title?: string; +} + +export const VoteStatus: React.FC = ({ + candidates, + totalVoters, + endTime, + players, + title + }) => { + const {t} = useTranslation(); + const [timeLeft, setTimeLeft] = useState(0); + + useEffect(() => { + if (!endTime) { + setTimeLeft(0); + return; + } + const interval = setInterval(() => { + const remaining = Math.max(0, Math.ceil((endTime - Date.now()) / 1000)); + setTimeLeft(remaining); + }, 100); + return () => clearInterval(interval); + }, [endTime]); + + // Calculate total votes cast + const totalVotes = candidates.reduce((acc, c) => acc + c.voters.length, 0); + const progress = totalVoters ? (totalVotes / totalVoters) * 100 : 0; + + return ( +
    + {/* Timer and Header */} +
    +

    {title || t('vote.progress')}

    +
    + + + {Math.floor(timeLeft / 60).toString().padStart(2, '0')}:{String(timeLeft % 60).padStart(2, '0')} + +
    +
    + + {/* Progress Bar */} + {totalVoters && ( +
    +
    + {t('vote.total')} + {totalVotes} / {totalVoters} +
    +
    +
    +
    +
    + )} + + {/* Candidates Grid */} +
    + {candidates.map((candidate) => { + const player = players.find(p => p.id === candidate.id); + return ( +
    + {/* Candidate Info */} +
    + {player?.name} +
    +

    {player?.name || `Candidate ${candidate.id}`}

    + + {candidate.voters.length} {t('vote.count')} + +
    +
    + + {/* Voters List */} +
    + {candidate.voters.length > 0 ? ( + candidate.voters.map(voterId => { + // Try to find voter by userId (assuming voterId is userId string) + // The backend sends userId as string for voters. + // Player.userId is key. + const voter = players.find(p => p.userId === voterId); + return ( +
    + + + {voter?.name || 'Unknown'} + +
    + ); + }) + ) : ( + {t('vote.noVotes')} + )} +
    +
    + ); + })} +
    + + {/* Not Voted List (Optional, maybe for future) */} + {totalVoters && (totalVotes < totalVoters) && ( +
    + {t('vote.waiting', {count: String(totalVoters - totalVotes)})} +
    + )} +
    + ); +}; diff --git a/src/dashboard/src/contexts/AuthContext.tsx b/src/dashboard/src/contexts/AuthContext.tsx index eb73d26..55090f1 100644 --- a/src/dashboard/src/contexts/AuthContext.tsx +++ b/src/dashboard/src/contexts/AuthContext.tsx @@ -1,10 +1,10 @@ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import React, {createContext, ReactNode, useContext, useEffect, useState} from 'react'; interface User { userId: string; username: string; avatar: string; - guildId: number; + guildId: string; // Changed from number to string role: 'JUDGE' | 'SPECTATOR' | 'BLOCKED' | 'PENDING'; } @@ -30,7 +30,7 @@ interface AuthProviderProps { children: ReactNode; } -export const AuthProvider: React.FC = ({ children }) => { +export const AuthProvider: React.FC = ({children}) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); @@ -88,7 +88,7 @@ export const AuthProvider: React.FC = ({ children }) => { }, []); return ( - + {children} ); diff --git a/src/dashboard/src/index.css b/src/dashboard/src/index.css index 60a250b..5b7b9ea 100644 --- a/src/dashboard/src/index.css +++ b/src/dashboard/src/index.css @@ -3,174 +3,174 @@ @tailwind utilities; @layer base { - * { - transition: background-color 0.2s ease, border-color 0.2s ease; - } + * { + 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; - } + body { + @apply bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100; + } } /* 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); - } + 0% { + transform: scale(1); + } - 50% { - transform: scale(1.05); - box-shadow: 0 0 15px rgba(99, 102, 241, 0.5); - border-color: #6366f1; - } + 50% { + transform: scale(1.05); + box-shadow: 0 0 15px rgba(99, 102, 241, 0.5); + border-color: #6366f1; + } - 100% { - transform: scale(1); - } + 100% { + transform: scale(1); + } } .animate-flash { - animation: flash-highlight 0.5s ease-in-out; + animation: flash-highlight 0.5s ease-in-out; } @keyframes slide-right-in { - from { - transform: translateX(-100%); - opacity: 0.5; - } + from { + transform: translateX(-100%); + opacity: 0.5; + } - to { - transform: translateX(0); - opacity: 1; - } + to { + transform: translateX(0); + opacity: 1; + } } @keyframes slide-left-in { - from { - transform: translateX(100%); - opacity: 0.5; - } + from { + transform: translateX(100%); + opacity: 0.5; + } - to { - transform: translateX(0); - opacity: 1; - } + 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; + 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; + 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; + 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; + --tw-enter-opacity: 0; + animation-name: enter; } .zoom-in-95 { - --tw-enter-scale: 0.95; + --tw-enter-scale: 0.95; } .zoom-in-75 { - --tw-enter-scale: 0.75; + --tw-enter-scale: 0.75; } .slide-in-from-bottom-2 { - --tw-enter-translate-y: 0.5rem; + --tw-enter-translate-y: 0.5rem; } .slide-in-from-bottom-4 { - --tw-enter-translate-y: 1rem; + --tw-enter-translate-y: 1rem; } .slide-in-from-right { - --tw-enter-translate-x: 100%; + --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); - } + 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; - } + to { + transform: translateX(-120%) scale(0.9); + opacity: 0; + } } @keyframes swipe-in-right { - from { - transform: translateX(120%) scale(0.9); - opacity: 0; - } + from { + transform: translateX(120%) scale(0.9); + opacity: 0; + } - to { - transform: translateX(0) scale(1); - opacity: 1; - } + 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; + 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; + animation: swipe-in-right 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; } @keyframes fade-in { - from { - opacity: 0; - } + from { + opacity: 0; + } - to { - opacity: 1; - } + to { + opacity: 1; + } } @keyframes scale-up { - from { - opacity: 0; - transform: scale(0.95); - } + from { + opacity: 0; + transform: scale(0.95); + } - to { - opacity: 1; - transform: scale(1); - } + to { + opacity: 1; + transform: scale(1); + } } .animate-fade-in { - animation: fade-in 0.2s ease-out forwards; + 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; + animation: scale-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; } \ No newline at end of file diff --git a/src/dashboard/src/lib/ThemeProvider.tsx b/src/dashboard/src/lib/ThemeProvider.tsx index 0deff4c..834ec33 100644 --- a/src/dashboard/src/lib/ThemeProvider.tsx +++ b/src/dashboard/src/lib/ThemeProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import React, {createContext, ReactNode, useContext, useEffect, useState} from 'react'; type Theme = 'light' | 'dark'; @@ -21,7 +21,7 @@ interface ThemeProviderProps { children: ReactNode; } -export const ThemeProvider: React.FC = ({ children }) => { +export const ThemeProvider: React.FC = ({children}) => { const [theme, setTheme] = useState(() => { // Check localStorage first const stored = localStorage.getItem('theme') as Theme | null; @@ -54,7 +54,7 @@ export const ThemeProvider: React.FC = ({ children }) => { }; return ( - + {children} ); diff --git a/src/dashboard/src/lib/api.ts b/src/dashboard/src/lib/api.ts index ca348a4..8808ce9 100644 --- a/src/dashboard/src/lib/api.ts +++ b/src/dashboard/src/lib/api.ts @@ -1,14 +1,18 @@ -const DEFAULT_BACKEND_URL = ''; // Empty string means use current origin (relative URLs) - export class ApiClient { private baseUrl: string; constructor() { this.baseUrl = this.getBackendUrl(); + console.log('ApiClient initialized with baseUrl:', this.baseUrl); + if (this.baseUrl !== '') { + console.warn('Backend URL is not empty! Forcing reset.'); + this.baseUrl = ''; + } } private getBackendUrl(): string { - return localStorage.getItem('backendUrl') || DEFAULT_BACKEND_URL; + // return localStorage.getItem('backendUrl') || DEFAULT_BACKEND_URL; + return ''; // Force local proxy } public setBackendUrl(url: string) { @@ -25,6 +29,7 @@ export class ApiClient { try { const response = await fetch(url, { + credentials: 'include', // Ensure cookies are sent ...options, headers: { 'Content-Type': 'application/json', @@ -72,15 +77,15 @@ export class ApiClient { } async assignRoles(guildId: string) { - return this.request(`/api/sessions/${guildId}/players/assign`, { method: 'POST' }); + 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' }); + 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' }); + return this.request(`/api/sessions/${guildId}/players/${userId}/police`, {method: 'POST'}); } async updatePlayerRoles(guildId: string, userId: string, roles: string[]) { @@ -91,11 +96,11 @@ export class ApiClient { } async reviveRole(guildId: string, userId: string, role: string) { - return this.request(`/api/sessions/${guildId}/players/${userId}/revive-role?role=${encodeURIComponent(role)}`, { method: 'POST' }); + 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' }); + return this.request(`/api/sessions/${guildId}/players/${userId}/revive`, {method: 'POST'}); } // Role management @@ -104,11 +109,11 @@ export class ApiClient { } async addRole(guildId: string, role: string, amount: number = 1) { - return this.request(`/api/sessions/${guildId}/roles/add?role=${encodeURIComponent(role)}&amount=${amount}`, { method: 'POST' }); + 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' }); + return this.request(`/api/sessions/${guildId}/roles/${encodeURIComponent(role)}?amount=${amount}`, {method: 'DELETE'}); } // Role Order @@ -136,67 +141,67 @@ export class ApiClient { async setPlayerCount(guildId: string, count: number) { return this.request(`/api/sessions/${guildId}/player-count`, { method: 'POST', - body: JSON.stringify({ count }) + body: JSON.stringify({count}) }); } // Speech endpoints async startSpeech(guildId: string) { - return this.request(`/api/sessions/${guildId}/speech/auto`, { method: 'POST' }); + return this.request(`/api/sessions/${guildId}/speech/auto`, {method: 'POST'}); } async skipSpeech(guildId: string) { - return this.request(`/api/sessions/${guildId}/speech/skip`, { method: 'POST' }); + return this.request(`/api/sessions/${guildId}/speech/skip`, {method: 'POST'}); } async interruptSpeech(guildId: string) { - return this.request(`/api/sessions/${guildId}/speech/interrupt`, { method: 'POST' }); + 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' }); + 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 }) + body: JSON.stringify({direction}) }); } async confirmSpeech(guildId: string) { - return this.request(`/api/sessions/${guildId}/speech/confirm`, { method: 'POST' }); + 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' }); + return this.request(`/api/sessions/${guildId}/start`, {method: 'POST'}); } async resetSession(guildId: string) { - return this.request(`/api/sessions/${guildId}/reset`, { method: 'POST' }); + 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 }) + body: JSON.stringify({duration}) }); } async muteAll(guildId: string) { - return this.request(`/api/sessions/${guildId}/speech/mute-all`, { method: 'POST' }); + 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' }); + 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 }) + body: JSON.stringify({role}) }); } diff --git a/src/dashboard/src/lib/i18n.ts b/src/dashboard/src/lib/i18n.ts index d46590c..32e9977 100644 --- a/src/dashboard/src/lib/i18n.ts +++ b/src/dashboard/src/lib/i18n.ts @@ -34,5 +34,5 @@ export const useTranslation = () => { return value; }; - return { t }; + return {t}; }; diff --git a/src/dashboard/src/lib/websocket.ts b/src/dashboard/src/lib/websocket.ts index dbd63de..559f38a 100644 --- a/src/dashboard/src/lib/websocket.ts +++ b/src/dashboard/src/lib/websocket.ts @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from 'react'; -import { api } from './api'; +import {useEffect, useRef, useState} from 'react'; +import {api} from './api'; type MessageHandler = (data: any) => void; @@ -9,9 +9,10 @@ export class WebSocketClient { private messageHandlers: Set = new Set(); private url: string; private reconnectAttempts = 0; + private guildId: string | null = null; private onConnectHandlers: Set<() => void> = new Set(); - private onDisconnectHandlers: Set<() => void> = new Set(); + private onDisconnectHandlers: Set<(event: CloseEvent) => void> = new Set(); constructor() { this.url = this.getWebSocketUrl(); @@ -19,20 +20,29 @@ export class WebSocketClient { private getWebSocketUrl(): string { const backendUrl = api.getConfiguredUrl(); + const query = this.guildId ? `?guildId=${this.guildId}` : ''; 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 `${protocol}//${host}/ws${query}`; } - return backendUrl.replace(/^http/, 'ws') + '/ws'; + return (backendUrl.replace(/^http/, 'ws') + '/ws').replace(/\/+$/, '') + query; } public get isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN; } - connect() { + connect(guildId?: string) { + if (guildId !== undefined) { + if (this.guildId !== guildId) { + this.guildId = guildId; + if (this.ws) { + this.disconnect(); + } + } + } + if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) { return; } @@ -69,9 +79,9 @@ export class WebSocketClient { }; this.ws.onclose = (event) => { - console.log(`WebSocket closed (code: ${event.code}), reconnecting...`); + console.log(`WebSocket closed (code: ${event.code}, reason: ${event.reason}), reconnecting...`); this.ws = null; - this.onDisconnectHandlers.forEach(h => h()); + this.onDisconnectHandlers.forEach(h => h(event)); this.reconnect(); }; } catch (error) { @@ -102,13 +112,14 @@ export class WebSocketClient { if (this.ws) { this.ws.onclose = null; // Prevent auto-reconnect on manual disconnect + const closeEvent = new CloseEvent('close', {code: 1000, reason: 'Manual disconnect'}); this.ws.close(); this.ws = null; - this.onDisconnectHandlers.forEach(h => h()); + this.onDisconnectHandlers.forEach(h => h(closeEvent)); } } - addConnectionHandlers(onConnect: () => void, onDisconnect: () => void) { + addConnectionHandlers(onConnect: () => void, onDisconnect: (event: CloseEvent) => void) { this.onConnectHandlers.add(onConnect); this.onDisconnectHandlers.add(onDisconnect); @@ -137,7 +148,7 @@ export class WebSocketClient { export const wsClient = new WebSocketClient(); // React hook for WebSocket using the singleton -export function useWebSocket(onMessage: MessageHandler) { +export function useWebSocket(onMessage: MessageHandler, guildId?: string, onSessionExpired?: () => void) { const [isConnected, setIsConnected] = useState(wsClient.isConnected); const onMessageRef = useRef(onMessage); @@ -149,7 +160,17 @@ export function useWebSocket(onMessage: MessageHandler) { // Subscribe to connection changes const unsubscribeConn = wsClient.addConnectionHandlers( () => setIsConnected(true), - () => setIsConnected(false) + (event) => { + setIsConnected(false); + if (event && event.reason && (event.reason.includes("No user in session") || event.reason.includes("Rejected WS connection"))) { + if (onSessionExpired) { + onSessionExpired(); + } else { + // Fallback if no handler provided (e.g. login page) + console.warn("Session expired handling not implemented in this context"); + } + } + } ); // Subscribe to messages @@ -158,14 +179,14 @@ export function useWebSocket(onMessage: MessageHandler) { }); // Ensure we are connected - wsClient.connect(); + wsClient.connect(guildId); // Heartbeat interval const interval = setInterval(() => { if (wsClient.isConnected) { - wsClient.send({ type: 'PING' }); + wsClient.send({type: 'PING'}); } else { - wsClient.connect(); // Force check if somehow stuck + wsClient.connect(guildId); // Force check if somehow stuck } }, 15000); @@ -176,5 +197,5 @@ export function useWebSocket(onMessage: MessageHandler) { }; }, []); - return { isConnected, ws: wsClient }; + return {isConnected, ws: wsClient}; } diff --git a/src/dashboard/src/locales/zh-TW.json b/src/dashboard/src/locales/zh-TW.json index 7d38ae1..5f2b387 100644 --- a/src/dashboard/src/locales/zh-TW.json +++ b/src/dashboard/src/locales/zh-TW.json @@ -1,314 +1,329 @@ { - "app": { - "title": "狼人殺助手", - "subtitle": "管理員儀表板與遊戲管理器" - }, - "login": { - "title": "狼人殺助手", - "subtitle": "管理員儀表板與遊戲管理器", - "loginButton": "使用 Discord 登入", - "restriction": "僅限狼人伺服器管理員" - }, - "auth": { - "loggingIn": "登入中..." - }, - "sidebar": { - "dashboard": "儀表板", - "switchServer": "切換伺服器", - "gameSettings": "遊戲設定", - "integrationGuide": "整合指南", - "botConnected": "機器人已連接", - "botDisconnected": "機器人未連接", - "spectator": "上帝視角", - "spectatorView": "上帝視角 / 亡者國度", - "speechManager": "發言管理系統", - "signOut": "登出", - "viewAsSpectator": "以觀眾身分檢視 (預覽)", - "backToJudge": "返回法官模式" - }, - "actions": { - "kill": "殺死" - }, - "accessDenied": { - "title": "存取限制", - "message": "身為遊戲中的活躍玩家,為了確保遊戲公平性,您目前無法進入管理儀表板。", - "suggestion": "如果您是法官或上帝,請確保您的 Discord 帳號已擁有正確的身分組。", - "back": "返回伺服器列表" - }, - "common": { - "cancel": "取消", - "confirm": "確認", - "none": "無", - "save": "儲存" - }, - "game": { - "lastWords": "遺言" - }, - "gameHeader": { - "currentPhase": "當前階段", - "timer": "計時器", - "startGame": "開始遊戲", - "nextPhase": "下一階段", - "lastWords": "遺言" - }, - "phases": { - "LOBBY": "大廳", - "NIGHT": "夜晚", - "DAY": "白天", - "VOTING": "投票", - "GAME_OVER": "遊戲結束" - }, - "players": { - "title": "玩家", - "alive": "存活", - "dead": "死亡", - "kill": "殺死", - "confirmKill": "確認殺死?", - "revive": "復活", - "edit": "編輯", - "transferPoliceDescription": "將警徽移交給另一位存活玩家。", - "selectTarget": "選擇對象...", - "noActionsAvailable": "此玩家目前沒有可用的特定操作。", - "killConfirmation": "確認處決", - "reviveRole": "復活身分: {role}", - "switchOrder": "切換身分順序" - }, - "spectator": { - "title": "上帝視角 / 亡者國度", - "subtitle": "在此查看遊戲進度及各陣營勝利條件", - "wolfGoal": "狼人目標:屠戮神職", - "wolfGoalDesc": "殺死所有神職人員", - "godsLeft": "神職陣營狀況", - "godsLeftDesc": "剩餘存活神職", - "wolvesLeft": "狼人陣營狀況", - "wolvesLeftDesc": "剩餘存活狼人", - "villagersLeft": "平民陣營狀況", - "villagersLeftDesc": "剩餘存活平民", - "jbbLeft": "金寶寶陣營狀況", - "jbbLeftDesc": "剩餘存活金寶寶", - "godGoal": "好人目標:驅逐狼人", - "godGoalDesc": "放逐或殺死所有狼人", - "villagerGoal": "狼人目標:屠戮平民", - "villagerGoalDesc": "殺死所有平民", - "jbbGoal": "狼人目標:尋找金寶寶", - "jbbGoalDesc": "殺死所有金寶寶", - "winConditions": "詳細勝利條件", - "goodWinCondition": "好人陣營:所有狼人死亡", - "wolfWinConditionNormal": "狼人陣營:所有神職死亡 或 所有平民死亡", - "wolfWinConditionDouble": "狼人陣營:所有神職死亡 或 所有金寶寶死亡" - }, - "roles": { - "title": "角色編輯", - "role": "角色", - "WEREWOLF": "狼人", - "VILLAGER": "村民", - "SEER": "預言家", - "WITCH": "女巫", - "HUNTER": "獵人", - "GUARD": "守衛", - "unknown": "未知身分" - }, - "status": { - "sheriff": "警長", - "jinBaoBao": "金寶寶", - "protected": "已保護", - "poisoned": "已中毒", - "silenced": "已禁言" - }, - "gameLog": { - "title": "遊戲日誌", - "placeholder": "輸入手動命令...", - "gameStarted": "遊戲已開始!", - "gamePaused": "遊戲計時器已被管理員暫停。", - "randomizeRoles": "正在隨機分配角色...", - "adminCommand": "管理員執行命令:/{action} 對 {player}", - "adminGlobalCommand": "管理員執行全域命令:/{action}", - "manualCommand": "手動命令:{cmd}" - }, - "globalCommands": { - "title": "全域管理員命令", - "randomAssign": "隨機分配角色", - "startGame": "正式開始遊戲", - "forceReset": "強制重置", - "confirmReset": "確認重置?", - "gameFlow": "遊戲流程", - "voiceTimer": "語音與計時", - "adminRoles": "管理員身分" - }, - "timer": { - "title": "手動計時器", - "start": "開始計時", - "minutes": "分", - "seconds": "秒" - }, - "voice": { - "muteAll": "全體靜音", - "unmuteAll": "全體解除靜音" - }, - "admin": { - "assignJudge": "指派法官", - "demoteJudge": "解除法官", - "forcePolice": "強制警長" - }, - "serverSelector": { - "title": "選擇伺服器", - "subtitle": "選擇要管理的狼人殺遊戲伺服器", - "loading": "載入伺服器列表...", - "loadError": "無法載入伺服器列表", - "retry": "重試", - "noSessions": "目前沒有進行中的遊戲", - "noSessionsHint": "在 Discord 中使用指令建立遊戲後再回來", - "players": "位玩家", - "backToLogin": "返回登入" - }, - "settings": { - "general": "一般設定", - "muteAfterSpeech": "發言後靜音", - "muteAfterSpeechDesc": "玩家發言結束後自動將其靜音", - "doubleIdentities": "雙重身分", - "doubleIdentitiesDesc": "每位玩家獲得兩個角色", - "playerCount": "玩家人數設定", - "totalPlayers": "總玩家數", - "playerCountDesc": "調整此數值將會自動建立或刪除遊戲頻道。" - }, - "buttons": { - "update": "更新" - }, - "speechManager": { - "startAuto": "開始自動發言", - "startPoliceEnroll": "啟動警長參選", - "skip": "強制換人", - "interrupt": "終止流程", - "noActiveSpeech": "目前沒有正在進行的自動發言流程。", - "noActiveSpeechJudge": "目前沒有正在進行的自動發言流程。身為法官,您可以隨時開始新的流程。", - "activeSpeech": "發言進行中", - "autoProcess": "自動流程", - "speaking": "正在發言", - "waiting": "等待發言", - "noMoreSpeakers": "沒有更多發言者", - "interruptVote": "下台投票", - "preparing": "準備中...", - "policeEnrollment": "警長參選", - "allowEnroll": "允許參選", - "allowUnEnroll": "允許退選", - "candidates": "參選名單", - "noCandidates": "目前無參選者...", - "waitingForPolice": "等待警長選擇發言順序...", - "waitingForPoliceSub": "警長正在選擇發言順序 (上警/下警)", - "forceUp": "強制往上 (死者/小號)", - "forceDown": "強制往下 (死者/小號)", - "judgeOverride": "法官強制操作" - }, - "progressOverlay": { - "operationFailed": "操作失敗", - "processing": "正在處理請求...", - "complete": "完成!", - "unknownError": "發生未知錯誤", - "close": "關閉", - "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": "玩家 (受限)" - } + "app": { + "title": "狼人殺助手", + "subtitle": "管理員儀表板與遊戲管理器" + }, + "login": { + "title": "狼人殺助手", + "subtitle": "管理員儀表板與遊戲管理器", + "loginButton": "使用 Discord 登入", + "restriction": "僅限狼人伺服器管理員" + }, + "auth": { + "loggingIn": "登入中...", + "sessionExpiredTitle": "登入已過期", + "sessionExpiredMessage": "您的登入工作階段已過期,請重新登入。", + "loginAgain": "重新登入" + }, + "sidebar": { + "dashboard": "儀表板", + "switchServer": "切換伺服器", + "gameSettings": "遊戲設定", + "integrationGuide": "整合指南", + "botConnected": "機器人已連接", + "botDisconnected": "機器人未連接", + "spectator": "上帝視角", + "spectatorView": "上帝視角 / 亡者國度", + "speechManager": "發言管理系統", + "signOut": "登出", + "viewAsSpectator": "以觀眾身分檢視 (預覽)", + "backToJudge": "返回法官模式" + }, + "actions": { + "kill": "殺死" + }, + "accessDenied": { + "title": "存取限制", + "message": "身為遊戲中的活躍玩家,為了確保遊戲公平性,您目前無法進入管理儀表板。", + "suggestion": "如果您是法官或上帝,請確保您的 Discord 帳號已擁有正確的身分組。", + "back": "返回伺服器列表" + }, + "common": { + "cancel": "取消", + "confirm": "確認", + "none": "無", + "save": "儲存" + }, + "game": { + "lastWords": "遺言" + }, + "gameHeader": { + "currentPhase": "當前階段", + "timer": "計時器", + "startGame": "開始遊戲", + "nextPhase": "下一階段", + "lastWords": "遺言" + }, + "phases": { + "LOBBY": "大廳", + "NIGHT": "夜晚", + "DAY": "白天", + "VOTING": "投票", + "GAME_OVER": "遊戲結束" + }, + "players": { + "title": "玩家", + "alive": "存活", + "dead": "死亡", + "kill": "殺死", + "confirmKill": "確認殺死?", + "revive": "復活", + "edit": "編輯", + "transferPoliceDescription": "將警徽移交給另一位存活玩家。", + "selectTarget": "選擇對象...", + "noActionsAvailable": "此玩家目前沒有可用的特定操作。", + "killConfirmation": "確認處決", + "reviveRole": "復活身分: {role}", + "switchOrder": "切換身分順序" + }, + "spectator": { + "title": "上帝視角 / 亡者國度", + "subtitle": "在此查看遊戲進度及各陣營勝利條件", + "wolfGoal": "狼人目標:屠戮神職", + "wolfGoalDesc": "殺死所有神職人員", + "godsLeft": "神職陣營狀況", + "godsLeftDesc": "剩餘存活神職", + "wolvesLeft": "狼人陣營狀況", + "wolvesLeftDesc": "剩餘存活狼人", + "villagersLeft": "平民陣營狀況", + "villagersLeftDesc": "剩餘存活平民", + "jbbLeft": "金寶寶陣營狀況", + "jbbLeftDesc": "剩餘存活金寶寶", + "godGoal": "好人目標:驅逐狼人", + "godGoalDesc": "放逐或殺死所有狼人", + "villagerGoal": "狼人目標:屠戮平民", + "villagerGoalDesc": "殺死所有平民", + "jbbGoal": "狼人目標:尋找金寶寶", + "jbbGoalDesc": "殺死所有金寶寶", + "winConditions": "詳細勝利條件", + "goodWinCondition": "好人陣營:所有狼人死亡", + "wolfWinConditionNormal": "狼人陣營:所有神職死亡 或 所有平民死亡", + "wolfWinConditionDouble": "狼人陣營:所有神職死亡 或 所有金寶寶死亡" + }, + "roles": { + "title": "角色編輯", + "role": "角色", + "WEREWOLF": "狼人", + "VILLAGER": "村民", + "SEER": "預言家", + "WITCH": "女巫", + "HUNTER": "獵人", + "GUARD": "守衛", + "unknown": "未知身分" + }, + "status": { + "sheriff": "警長", + "jinBaoBao": "金寶寶", + "protected": "已保護", + "poisoned": "已中毒", + "silenced": "已禁言" + }, + "gameLog": { + "title": "遊戲日誌", + "placeholder": "輸入手動命令...", + "gameStarted": "遊戲已開始!", + "gamePaused": "遊戲計時器已被管理員暫停。", + "randomizeRoles": "正在隨機分配角色...", + "adminCommand": "管理員執行命令:/{action} 對 {player}", + "adminGlobalCommand": "管理員執行全域命令:/{action}", + "manualCommand": "手動命令:{cmd}" + }, + "globalCommands": { + "title": "全域管理員命令", + "randomAssign": "隨機分配角色", + "startGame": "正式開始遊戲", + "forceReset": "強制重置", + "confirmReset": "確認重置?", + "gameFlow": "遊戲流程", + "voiceTimer": "語音與計時", + "adminRoles": "管理員身分" + }, + "timer": { + "title": "手動計時器", + "start": "開始計時", + "minutes": "分", + "seconds": "秒" + }, + "voice": { + "muteAll": "全體靜音", + "unmuteAll": "全體解除靜音" + }, + "admin": { + "assignJudge": "指派法官", + "demoteJudge": "解除法官", + "forcePolice": "強制警長" + }, + "serverSelector": { + "title": "選擇伺服器", + "subtitle": "選擇要管理的狼人殺遊戲伺服器", + "loading": "載入伺服器列表...", + "loadError": "無法載入伺服器列表", + "retry": "重試", + "noSessions": "目前沒有進行中的遊戲", + "noSessionsHint": "在 Discord 中使用指令建立遊戲後再回來", + "players": "位玩家", + "backToLogin": "返回登入", + "switching": "切換伺服器中..." + }, + "settings": { + "general": "一般設定", + "muteAfterSpeech": "發言後靜音", + "muteAfterSpeechDesc": "玩家發言結束後自動將其靜音", + "doubleIdentities": "雙重身分", + "doubleIdentitiesDesc": "每位玩家獲得兩個角色", + "playerCount": "玩家人數設定", + "totalPlayers": "總玩家數", + "playerCountDesc": "調整此數值將會自動建立或刪除遊戲頻道。" + }, + "buttons": { + "update": "更新" + }, + "speechManager": { + "startAuto": "開始自動發言", + "startPoliceEnroll": "啟動警長參選", + "skip": "強制換人", + "interrupt": "終止流程", + "noActiveSpeech": "目前沒有正在進行的自動發言流程。", + "noActiveSpeechJudge": "目前沒有正在進行的自動發言流程。身為法官,您可以隨時開始新的流程。", + "activeSpeech": "發言進行中", + "autoProcess": "自動流程", + "speaking": "正在發言", + "waiting": "等待發言", + "noMoreSpeakers": "沒有更多發言者", + "interruptVote": "下台投票", + "preparing": "準備中...", + "policeUnenrollment": "警長退選", + "policeEnrollment": "警長參選", + "allowEnroll": "允許參選", + "allowUnEnroll": "允許退選", + "candidates": "參選名單", + "noCandidates": "目前無參選者...", + "waitingForPolice": "等待警長選擇發言順序...", + "waitingForPoliceSub": "警長正在選擇發言順序 (警上/警下)", + "forceUp": "強制往上", + "forceDown": "強制往下", + "judgeOverride": "法官強制操作" + }, + "progressOverlay": { + "operationFailed": "操作失敗", + "processing": "正在處理請求...", + "complete": "完成!", + "unknownError": "發生未知錯誤", + "close": "關閉", + "ok": "確定", + "resetTitle": "重置遊戲" + }, + "vote": { + "policeElection": "警長選舉", + "expelVote": "放逐投票", + "progress": "投票進行中", + "total": "總票數", + "count": "票", + "noVotes": "尚未有人投票", + "waiting": "等待 {count} 位玩家..." + }, + "playerEdit": { + "title": "編輯玩家", + "subtitle": "修改 {player} 的角色與狀態", + "currentRoles": "目前角色", + "noRoles": "尚未分配角色", + "addRole": "新增角色", + "selectRole": "選擇角色...", + "add": "新增", + "removeRole": "移除角色", + "switchOrder": "交換順序", + "lockPosition": "鎖定位置", + "unlockPosition": "解鎖位置", + "positionLocked": "位置已鎖定", + "positionUnlocked": "位置未鎖定", + "close": "關閉" + }, + "deathConfirm": { + "title": "確認處決", + "message": "確定要處決 {player} 嗎?", + "lastWordsOption": "允許遺言", + "cancel": "取消", + "confirm": "確認處決" + }, + "gameSettings": { + "title": "遊戲設定", + "subtitle": "管理遊戲規則與行為", + "generalSettings": "一般設定", + "doubleIdentities": "雙重身分", + "doubleIdentitiesDesc": "每位玩家獲得兩個角色", + "muteAfterSpeech": "發言後靜音", + "muteAfterSpeechDesc": "玩家發言結束後自動靜音", + "roleManagement": "角色管理", + "roleManagementDesc": "新增或移除遊戲中的角色", + "currentRoles": "目前角色", + "noRoles": "尚未設定角色", + "addRole": "新增角色", + "selectRole": "選擇角色...", + "amount": "數量", + "add": "新增", + "remove": "移除", + "backToDashboard": "返回儀表板" + }, + "settingsModal": { + "backendUrl": "後端伺服器網址", + "urlPlaceholder": "(保持空白以使用目前網域)", + "urlHint": "保持空白則自動使用目前來源。如果您在開發環境(如可以使用 127.0.0.1:8080),也可以手動輸入網址。", + "testConnection": "測試連接", + "testing": "測試中...", + "connectionSuccess": "連接成功", + "connectionFailed": "連接失敗" + }, + "search": { + "placeholder": "搜尋玩家...", + "noResults": "找不到玩家" + }, + "modal": { + "assignJudge": "指派法官", + "demoteJudge": "解除法官", + "forcePolice": "強制警長" + }, + "tooltips": { + "skipSpeaker": "跳過當前發言者,換下一位", + "interruptSpeech": "終止整個發言流程", + "switchToLight": "切換到淺色模式", + "switchToDark": "切換到深色模式" + }, + "messages": { + "unassigned": "未指派", + "player": "玩家", + "speaking": "發言中", + "progress": "進度", + "totalCount": "總數", + "noRolesConfigured": "尚無角色配置", + "selectOrEnterRole": "選擇或輸入角色名稱...", + "add": "新增", + "lockRoleOrder": "鎖定角色順序", + "autoMuteAfterSpeech": "發言後自動閉麥", + "autoMuteDesc": "玩家發言結束後自動將其靜音", + "roleSettings": "設定", + "randomAssignRoles": "隨機分配角色" + }, + "errors": { + "actionFailed": "{action} 失敗", + "unknownError": "未知錯誤", + "error": "錯誤", + "resetFailed": "重置失敗", + "assignFailed": "分配失敗" + }, + "overlayMessages": { + "resetting": "正在重置遊戲...", + "resetSuccess": "重置成功!遊戲已恢復到初始狀態。", + "requestingAssign": "正在向伺服器請求分配...", + "assignSuccess": "分配成功!所有玩家已收到身分通知。", + "updatingPlayerCount": "正在更新玩家人數並調整遊戲配置...", + "playerCountUpdateSuccess": "玩家人數更新成功。", + "processing": "處理中..." + }, + "userRoles": { + "JUDGE": "法官", + "SPECTATOR": "上帝模式", + "PENDING": "等待中", + "BLOCKED": "玩家 (受限)", + "null": "未知" + } } \ No newline at end of file diff --git a/src/dashboard/src/main.tsx b/src/dashboard/src/main.tsx index f99620b..8ea662a 100644 --- a/src/dashboard/src/main.tsx +++ b/src/dashboard/src/main.tsx @@ -1,7 +1,7 @@ -import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import { ThemeProvider } from './lib/ThemeProvider'; -import { AuthProvider } from './contexts/AuthContext'; +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'; @@ -10,7 +10,7 @@ root.render( - + diff --git a/src/dashboard/src/mockData.ts b/src/dashboard/src/mockData.ts index dcf2c7a..c31f441 100644 --- a/src/dashboard/src/mockData.ts +++ b/src/dashboard/src/mockData.ts @@ -1,29 +1,28 @@ - -import { Player } from './types'; +import {Player} from './types'; export const MOCK_AVATARS = [ - 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix', - 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka', - 'https://api.dicebear.com/7.x/avataaars/svg?seed=Zack', - 'https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah', - 'https://api.dicebear.com/7.x/avataaars/svg?seed=John', - 'https://api.dicebear.com/7.x/avataaars/svg?seed=Mila', - 'https://api.dicebear.com/7.x/avataaars/svg?seed=Leo', - 'https://api.dicebear.com/7.x/avataaars/svg?seed=Kai', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Zack', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=John', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Mila', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Leo', + 'https://api.dicebear.com/7.x/avataaars/svg?seed=Kai', ]; -export const INITIAL_PLAYERS: Player[] = Array.from({ length: 8 }).map((_, i) => ({ - id: `p-${i}`, - discordId: `u-${i}`, - name: `Player ${i + 1}`, - avatar: MOCK_AVATARS[i], - roles: i === 0 ? ['WEREWOLF'] : i === 1 ? ['SEER'] : i === 2 ? ['WITCH'] : ['VILLAGER'], - deadRoles: [], - isAlive: true, - isSheriff: false, - isJinBaoBao: i === 3, - isProtected: false, - isPoisoned: false, - isSilenced: false, - hasVoted: false, +export const INITIAL_PLAYERS: Player[] = Array.from({length: 8}).map((_, i) => ({ + id: `p-${i}`, + discordId: `u-${i}`, + name: `Player ${i + 1}`, + avatar: MOCK_AVATARS[i], + roles: i === 0 ? ['WEREWOLF'] : i === 1 ? ['SEER'] : i === 2 ? ['WITCH'] : ['VILLAGER'], + deadRoles: [], + isAlive: true, + isSheriff: false, + isJinBaoBao: i === 3, + isProtected: false, + isPoisoned: false, + isSilenced: false, + hasVoted: false, })); diff --git a/src/dashboard/src/types.ts b/src/dashboard/src/types.ts index ccc6281..c86d523 100644 --- a/src/dashboard/src/types.ts +++ b/src/dashboard/src/types.ts @@ -1,84 +1,100 @@ // 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[]; + 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; + 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; } // 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; + phase: GamePhase; + dayCount: number; + timerSeconds: number; + doubleIdentities?: boolean; + availableRoles?: string[]; + players: Player[]; + logs: LogEntry[]; + speech?: SpeechState; + police?: PoliceState; + expel?: ExpelState; } export interface PoliceState { - allowEnroll: boolean; - allowUnEnroll: boolean; - candidates: string[]; // List of Player IDs (internal IDs) + state: 'NONE' | 'ENROLLMENT' | 'SPEECH' | 'UNENROLLMENT' | 'VOTING' | 'FINISHED'; + stageEndTime?: number; + allowEnroll: boolean; + allowUnEnroll: boolean; + candidates: { + id: string; // Player ID (internal) + quit?: boolean; + voters: string[]; // List of User IDs (or Player IDs depending on backend mapping) + }[]; +} + +export interface ExpelState { + voting: boolean; + candidates: { + id: string; + quit?: boolean; + voters: string[]; + }[]; } export interface SpeechState { - order: string[]; // List of Player IDs (internal IDs) - currentSpeakerId?: string; - endTime: number; - totalTime: number; - isPaused?: boolean; - interruptVotes?: string[]; + order: string[]; // List of Player IDs (internal IDs) + currentSpeakerId?: string; + endTime: number; + totalTime: number; + isPaused?: boolean; + interruptVotes?: string[]; } export interface Player { - id: string; - name: string; - userId?: string; // Discord User ID - username?: string; // Discord username - roles: string[]; // Array to support double identities - deadRoles: string[]; // Track which roles are dead - avatar: string; - isAlive: boolean; - isSheriff: boolean; - isJinBaoBao: boolean; - isProtected: boolean; - isPoisoned: boolean; - isSilenced: boolean; - isDuplicated?: boolean; - isJudge?: boolean; - rolePositionLocked?: boolean; + id: string; + name: string; + userId?: string; // Discord User ID + username?: string; // Discord username + roles: string[]; // Array to support double identities + deadRoles: string[]; // Track which roles are dead + avatar: string; + isAlive: boolean; + isSheriff: boolean; + isJinBaoBao: boolean; + isProtected: boolean; + isPoisoned: boolean; + isSilenced: boolean; + isDuplicated?: boolean; + isJudge?: boolean; + rolePositionLocked?: boolean; } export interface LogEntry { - id: string; - timestamp: string; - message: string; - type: 'info' | 'action' | 'alert'; + id: string; + timestamp: string; + message: string; + type: 'info' | 'action' | 'alert'; } diff --git a/src/dashboard/tailwind.config.js b/src/dashboard/tailwind.config.js index 73324ed..019c249 100644 --- a/src/dashboard/tailwind.config.js +++ b/src/dashboard/tailwind.config.js @@ -1,12 +1,12 @@ /** @type {import('tailwindcss').Config} */ export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], - darkMode: 'class', - theme: { - extend: {}, - }, - plugins: [], + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + darkMode: 'class', + theme: { + extend: {}, + }, + plugins: [], } \ No newline at end of file diff --git a/src/dashboard/tsconfig.json b/src/dashboard/tsconfig.json index 1067bd0..6796019 100644 --- a/src/dashboard/tsconfig.json +++ b/src/dashboard/tsconfig.json @@ -2,10 +2,13 @@ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], "module": "ESNext", "skipLibCheck": true, - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, @@ -13,13 +16,18 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["."], - "references": [{ "path": "./tsconfig.node.json" }] + "include": [ + "." + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] } \ No newline at end of file diff --git a/src/dashboard/tsconfig.node.json b/src/dashboard/tsconfig.node.json index 099658c..73dbb0b 100644 --- a/src/dashboard/tsconfig.node.json +++ b/src/dashboard/tsconfig.node.json @@ -6,5 +6,7 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts"] + "include": [ + "vite.config.ts" + ] } \ No newline at end of file diff --git a/src/dashboard/vite.config.ts b/src/dashboard/vite.config.ts index 8f1c758..77255f0 100644 --- a/src/dashboard/vite.config.ts +++ b/src/dashboard/vite.config.ts @@ -1,20 +1,20 @@ -import { defineConfig } from 'vite' +import {defineConfig} from 'vite' 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'] - } + 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/main/java/dev/robothanzo/werewolf/WerewolfApplication.java b/src/main/java/dev/robothanzo/werewolf/WerewolfApplication.java new file mode 100644 index 0000000..ac99992 --- /dev/null +++ b/src/main/java/dev/robothanzo/werewolf/WerewolfApplication.java @@ -0,0 +1,120 @@ +package dev.robothanzo.werewolf; + +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers; +import dev.robothanzo.werewolf.service.DiscordService; +import dev.robothanzo.werewolf.service.GameSessionService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.dv8tion.jda.api.JDA; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Enumeration; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.zip.ZipEntry; + +@Slf4j +@SpringBootApplication +@EnableScheduling +public class WerewolfApplication { + + public static final long AUTHOR = 466769036122783744L; + public static final List SERVER_CREATORS = List.of(466769036122783744L, 616590798989033502L, + 451672040227864587L); + public static final List ROLES = List.of( + "狼人", "女巫", "獵人", "預言家", "平民", "狼王", "狼美人", "白狼王", "夢魘", "混血兒", "守衛", "騎士", "白癡", + "守墓人", "魔術師", "黑市商人", "邱比特", "盜賊", "石像鬼", "狼兄", "狼弟", "複製人", "血月使者", "惡靈騎士", "通靈師", "機械狼", "獵魔人"); + + // Static bridges for legacy code + public static JDA jda; + public static GameSessionService gameSessionService; + public static dev.robothanzo.werewolf.service.RoleService roleService; + public static dev.robothanzo.werewolf.service.GameActionService gameActionService; + public static dev.robothanzo.werewolf.service.PoliceService policeService; + public static dev.robothanzo.werewolf.service.PlayerService playerService; + public static dev.robothanzo.werewolf.service.SpeechService speechService; + public static AudioPlayerManager playerManager = new DefaultAudioPlayerManager(); + + static void main(String[] args) { + // Extract sounds before Spring starts + extractSoundFiles(); + SpringApplication.run(WerewolfApplication.class, args); + } + + // Component to populate static fields from Spring Context + @Component + @RequiredArgsConstructor + static class StaticBridge { + private final GameSessionService gameSessionServiceBean; + private final DiscordService discordServiceBean; + private final dev.robothanzo.werewolf.service.RoleService roleServiceBean; + private final dev.robothanzo.werewolf.service.GameActionService gameActionServiceBean; + private final dev.robothanzo.werewolf.service.PoliceService policeServiceBean; + private final dev.robothanzo.werewolf.service.PlayerService playerServiceBean; + private final dev.robothanzo.werewolf.service.SpeechService speechServiceBean; + + @PostConstruct + public void init() { + log.info("Initializing Static Bridge..."); + dev.robothanzo.werewolf.database.Database.initDatabase(); // Initialize legacy DB + WerewolfApplication.gameSessionService = gameSessionServiceBean; + WerewolfApplication.roleService = roleServiceBean; + WerewolfApplication.gameActionService = gameActionServiceBean; + WerewolfApplication.policeService = policeServiceBean; + WerewolfApplication.playerService = playerServiceBean; + WerewolfApplication.speechService = speechServiceBean; + WerewolfApplication.jda = discordServiceBean.getJDA(); + + // AudioPlayerManager setup if needed + AudioSourceManagers.registerRemoteSources(playerManager); + AudioSourceManagers.registerLocalSource(playerManager); + } + } + + public static void extractSoundFiles() { + try { + JarFile jarFile = new JarFile( + new File(WerewolfApplication.class.getProtectionDomain().getCodeSource().getLocation().toURI())); + File soundFolder = new File("sounds"); + if (!soundFolder.exists()) { + soundFolder.mkdir(); + } + // Logic to clean and extract (simplified from original to avoid full deletion + // risk if not intent) + // Original code deleted file if it was a file named "soundFolder". + + for (Enumeration em = jarFile.entries(); em.hasMoreElements(); ) { + String s = em.nextElement().toString(); + + if (s.startsWith(("sounds/")) && s.endsWith(".mp3")) { + ZipEntry entry = jarFile.getEntry(s); + File outputFile = new File(soundFolder, s.split("/")[s.split("/").length - 1]); + // Only write if doesn't exist or explicit overwrite logic? + // Legacy code overwrote. + InputStream inStream = jarFile.getInputStream(entry); + OutputStream out = new FileOutputStream(outputFile); + int c; + while ((c = inStream.read()) != -1) { + out.write(c); + } + inStream.close(); + out.close(); + } + } + jarFile.close(); + } catch (Exception e) { + log.warn("Failed to extract sound files (ignore if running in IDE): {}", e.getMessage()); + } + } +} diff --git a/src/main/java/dev/robothanzo/werewolf/WerewolfHelper.java b/src/main/java/dev/robothanzo/werewolf/WerewolfHelper.java deleted file mode 100644 index 09c9108..0000000 --- a/src/main/java/dev/robothanzo/werewolf/WerewolfHelper.java +++ /dev/null @@ -1,108 +0,0 @@ -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; -import dev.robothanzo.werewolf.listeners.MessageListener; -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; -import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.JDABuilder; -import net.dv8tion.jda.api.audio.AudioModuleConfig; -import net.dv8tion.jda.api.entities.Activity; -import net.dv8tion.jda.api.requests.GatewayIntent; -import net.dv8tion.jda.api.utils.ChunkingFilter; -import net.dv8tion.jda.api.utils.MemberCachePolicy; -import net.dv8tion.jda.api.utils.cache.CacheFlag; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.EnumSet; -import java.util.Enumeration; -import java.util.List; -import java.util.jar.JarEntry; -import java.util.jar.JarFile; -import java.util.zip.ZipEntry; - -@Slf4j -public class WerewolfHelper { - public static final long AUTHOR = 466769036122783744L; - public static final List SERVER_CREATORS = List.of(466769036122783744L, 616590798989033502L, 451672040227864587L); - public static final List ROLES = List.of( - "狼人", "女巫", "獵人", "預言家", "平民", "狼王", "狼美人", "白狼王", "夢魘", "混血兒", "守衛", "騎士", "白癡", - "守墓人", "魔術師", "黑市商人", "邱比特", "盜賊", "石像鬼", "狼兄", "狼弟", "複製人", "血月使者", "惡靈騎士", "通靈師", "機械狼", "獵魔人" - ); - public static JDA jda; - public static WebServer webServer; - public static AudioPlayerManager playerManager = new DefaultAudioPlayerManager(); - - @SneakyThrows - public static void main(String[] args) { - extractSoundFiles(); - Database.initDatabase(); - AudioSourceManagers.registerLocalSource(playerManager); - jda = JDABuilder.createDefault(System.getenv("TOKEN")) - .enableIntents(GatewayIntent.GUILD_MEMBERS, GatewayIntent.MESSAGE_CONTENT) - .setChunkingFilter(ChunkingFilter.ALL) - .setMemberCachePolicy(MemberCachePolicy.ALL) - .enableCache(EnumSet.allOf(CacheFlag.class)) - .disableCache(CacheFlag.ACTIVITY, CacheFlag.CLIENT_STATUS, CacheFlag.ONLINE_STATUS) - .addEventListeners(new GuildJoinListener(), new MemberJoinListener(), new MessageListener(), new ButtonListener()) - .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(); - } - - @SneakyThrows - public static void extractSoundFiles() { - JarFile jarFile = new JarFile(new File(WerewolfHelper.class.getProtectionDomain().getCodeSource().getLocation() - .toURI())); - File soundFolder = new File("sounds"); - if (!soundFolder.exists()) { - soundFolder.mkdir(); - } - if (soundFolder.isFile()) { - soundFolder.delete(); - soundFolder.mkdir(); - } - - for (Enumeration em = jarFile.entries(); em.hasMoreElements(); ) { - String s = em.nextElement().toString(); - - if (s.startsWith(("sounds/")) && s.endsWith(".mp3")) { - ZipEntry entry = jarFile.getEntry(s); - File outputFile = new File(soundFolder, s.split("/")[s.split("/").length - 1]); - InputStream inStream = jarFile.getInputStream(entry); - OutputStream out = new FileOutputStream(outputFile); - int c; - while ((c = inStream.read()) != -1) { - out.write(c); - } - inStream.close(); - out.close(); - } - } - jarFile.close(); - } -} diff --git a/src/main/java/dev/robothanzo/werewolf/audio/Audio.java b/src/main/java/dev/robothanzo/werewolf/audio/Audio.java index 98d035a..5577a1c 100644 --- a/src/main/java/dev/robothanzo/werewolf/audio/Audio.java +++ b/src/main/java/dev/robothanzo/werewolf/audio/Audio.java @@ -5,7 +5,7 @@ import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import dev.robothanzo.werewolf.WerewolfHelper; +import dev.robothanzo.werewolf.WerewolfApplication; import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; import net.dv8tion.jda.api.managers.AudioManager; @@ -18,9 +18,9 @@ public static void play(Resource resource, AudioChannel channel) { try { AudioManager audioManager = channel.getGuild().getAudioManager(); audioManager.openAudioConnection(channel); - AudioPlayer player = WerewolfHelper.playerManager.createPlayer(); + AudioPlayer player = WerewolfApplication.playerManager.createPlayer(); audioManager.setSendingHandler(new AudioPlayerSendHandler(player)); - WerewolfHelper.playerManager.loadItem("sounds/" + resource + ".mp3", new AudioLoadResultHandler() { + WerewolfApplication.playerManager.loadItem("sounds/" + resource + ".mp3", new AudioLoadResultHandler() { @Override public void trackLoaded(AudioTrack track) { player.startTrack(track, false); @@ -45,7 +45,8 @@ public void loadFailed(FriendlyException exception) { } public enum Resource { - EXPEL_POLL, POLICE_ENROLL, POLICE_POLL, TIMER_ENDED, ENROLL_10S_REMAINING, POLL_10S_REMAINING, TIMER_30S_REMAINING; + EXPEL_POLL, POLICE_ENROLL, POLICE_POLL, TIMER_ENDED, ENROLL_10S_REMAINING, POLL_10S_REMAINING, + TIMER_30S_REMAINING; @Override public String toString() { diff --git a/src/main/java/dev/robothanzo/werewolf/commands/Player.java b/src/main/java/dev/robothanzo/werewolf/commands/Player.java index f4006f0..9b608eb 100644 --- a/src/main/java/dev/robothanzo/werewolf/commands/Player.java +++ b/src/main/java/dev/robothanzo/werewolf/commands/Player.java @@ -3,7 +3,7 @@ import dev.robothanzo.jda.interactions.annotations.slash.Command; import dev.robothanzo.jda.interactions.annotations.slash.Subcommand; import dev.robothanzo.jda.interactions.annotations.slash.options.Option; -import dev.robothanzo.werewolf.WerewolfHelper; +import dev.robothanzo.werewolf.WerewolfApplication; import dev.robothanzo.werewolf.database.documents.Session; import dev.robothanzo.werewolf.utils.CmdUtils; import dev.robothanzo.werewolf.utils.MsgUtils; @@ -11,14 +11,14 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.components.selections.EntitySelectMenu; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.EntitySelectInteractionEvent; -import net.dv8tion.jda.api.components.actionrow.ActionRow; -import net.dv8tion.jda.api.components.buttons.Button; -import net.dv8tion.jda.api.components.selections.EntitySelectMenu; import org.jetbrains.annotations.Nullable; import java.util.*; @@ -31,7 +31,8 @@ public class Player { public static Map transferPoliceSessions = new HashMap<>(); // key is guild ID - public static void transferPolice(Session session, Guild guild, Session.Player player, @Nullable Runnable callback) { + public static void transferPolice(Session session, Guild guild, Session.Player player, + @Nullable Runnable callback) { if (player.isPolice()) { assert player.getUserId() != null; transferPoliceSessions.put(guild.getIdLong(), TransferPoliceSession.builder() @@ -39,21 +40,26 @@ public static void transferPolice(Session session, Guild guild, Session.Player p .senderId(player.getUserId()) .callback(callback) .build()); - EntitySelectMenu.Builder selectMenu = EntitySelectMenu.create("selectNewPolice", EntitySelectMenu.SelectTarget.USER) + EntitySelectMenu.Builder selectMenu = EntitySelectMenu + .create("selectNewPolice", EntitySelectMenu.SelectTarget.USER) .setMinValues(1) .setMaxValues(1); for (Session.Player p : session.fetchAlivePlayers().values()) { assert p.getUserId() != null; - if (Objects.equals(p.getUserId(), player.getUserId())) continue; - User user = WerewolfHelper.jda.getUserById(p.getUserId()); + if (Objects.equals(p.getUserId(), player.getUserId())) + continue; + User user = WerewolfApplication.jda.getUserById(p.getUserId()); assert user != null; transferPoliceSessions.get(guild.getIdLong()).getPossibleRecipientIds().add(p.getUserId()); } - Message message = Objects.requireNonNull(guild.getTextChannelById(session.getCourtTextChannelId())).sendMessageEmbeds( + Message message = Objects + .requireNonNull(guild.getTextChannelById(session.getCourtTextChannelId())).sendMessageEmbeds( new EmbedBuilder() .setTitle("移交警徽").setColor(MsgUtils.getRandomColor()) .setDescription("請選擇要移交警徽的對象,若要撕掉警徽,請按下撕毀按鈕\n請在30秒內做出選擇,否則警徽將被自動撕毀").build()) - .setComponents(ActionRow.of(selectMenu.build()), ActionRow.of(Button.success("confirmNewPolice", "移交"), Button.danger("destroyPolice", "撕毀"))) + .setComponents(ActionRow.of(selectMenu.build()), + ActionRow.of(Button.success("confirmNewPolice", "移交"), + Button.danger("destroyPolice", "撕毀"))) .complete(); CmdUtils.schedule(() -> { if (transferPoliceSessions.remove(guild.getIdLong()) != null) { @@ -61,15 +67,20 @@ public static void transferPolice(Session session, Guild guild, Session.Player p } }, 30000); } - if (callback != null) callback.run(); + if (callback != null) + callback.run(); } - public static boolean playerDied(Session session, Member user, boolean lastWords, boolean isExpelled) { // returns whether the action succeeded - Guild guild = Objects.requireNonNull(WerewolfHelper.jda.getGuildById(session.getGuildId())); + public static boolean playerDied(Session session, Member user, boolean lastWords, boolean isExpelled) { // returns + // whether + // the + // action + // succeeded + Guild guild = Objects.requireNonNull(WerewolfApplication.jda.getGuildById(session.getGuildId())); Role spectatorRole = Objects.requireNonNull(guild.getRoleById(session.getSpectatorRoleId())); for (Map.Entry player : new LinkedList<>(session.getPlayers().entrySet())) { if (Objects.equals(user.getIdLong(), player.getValue().getUserId())) { - + // Check if already fully dead if (!player.getValue().isAlive()) { return false; @@ -89,30 +100,39 @@ public static boolean playerDied(Session session, Member user, boolean lastWords 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; + break; } } - // Persist the dead role update immediately to ensure consistency - Session.fetchCollection().updateOne(eq("guildId", session.getGuildId()), set("players", session.getPlayers())); + // 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); - } + // Log the death + if (killedRole != null) { + Map metadata = new HashMap<>(); + metadata.put("playerId", player.getValue().getId()); + metadata.put("playerName", player.getValue().getNickname()); + metadata.put("killedRole", killedRole); + metadata.put("isExpelled", isExpelled); + session.addLog(dev.robothanzo.werewolf.database.documents.LogType.PLAYER_DIED, + player.getValue().getNickname() + " 的 " + killedRole + " 身份已死亡", + metadata); + + // Send message to court channel + TextChannel courtChannel = guild.getTextChannelById(session.getCourtTextChannelId()); + if (courtChannel != null) { + courtChannel + .sendMessage("**:skull: " + user.getAsMention() + " 已死亡**") + .queue(); + } + } - // Check game ended logic with the newly killed role + // 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()); @@ -126,7 +146,7 @@ public static boolean playerDied(Session session, Member user, boolean lastWords lastWords = false; } } - + // Check if player is still alive (has remaining roles) if (player.getValue().isAlive()) { // Calculate remaining roles for the message @@ -141,41 +161,53 @@ public static boolean playerDied(Session session, Member user, boolean lastWords // Not fully dead, passed out one role Objects.requireNonNull(guild.getTextChannelById(player.getValue().getChannelId())) .sendMessage("因為你死了,所以你的角色變成了 " + remainingRoleName).queue(); - Session.fetchCollection().updateOne(eq("guildId", session.getGuildId()), set("players", session.getPlayers())); + Session.fetchCollection().updateOne(eq("guildId", session.getGuildId()), + set("players", session.getPlayers())); if (lastWords) { - Speech.lastWordsSpeech(guild, Objects.requireNonNull(guild.getTextChannelById(session.getCourtTextChannelId())), player.getValue(), null); + WerewolfApplication.speechService.startLastWordsSpeech(guild, + session.getCourtTextChannelId(), + player.getValue(), null); } return true; } - + // 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. + 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. + // 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(); + Session.fetchCollection().updateOne(eq("guildId", newSession.getGuildId()), + set("players", newSession.getPlayers())); + WerewolfApplication.gameSessionService.broadcastSessionUpdate(newSession); + Objects.requireNonNull(guild.getTextChannelById(newSession.getCourtTextChannelId())) + .sendMessage(user.getAsMention() + " 是白癡,所以他會待在場上並繼續發言").queue(); } else { guild.modifyMemberRoles(user, spectatorRole).queue(); - Session.fetchCollection().updateOne(eq("guildId", newSession.getGuildId()), set("players", newSession.getPlayers())); + Session.fetchCollection().updateOne(eq("guildId", newSession.getGuildId()), + set("players", newSession.getPlayers())); player.getValue().updateNickname(user); - WerewolfHelper.webServer.broadcastSessionUpdate(newSession); + WerewolfApplication.gameSessionService.broadcastSessionUpdate(newSession); } }); - + if (lastWords) { - Speech.lastWordsSpeech(guild, Objects.requireNonNull(guild.getTextChannelById(session.getCourtTextChannelId())), player.getValue(), die); + WerewolfApplication.speechService.startLastWordsSpeech(guild, + session.getCourtTextChannelId(), + player.getValue(), die); } else { die.run(); } @@ -188,9 +220,9 @@ public static boolean playerDied(Session session, Member user, boolean lastWords } public static boolean playerRevived(Session session, Member user, String roleToRevive) { - Guild guild = Objects.requireNonNull(WerewolfHelper.jda.getGuildById(session.getGuildId())); + Guild guild = Objects.requireNonNull(WerewolfApplication.jda.getGuildById(session.getGuildId())); Role spectatorRole = Objects.requireNonNull(guild.getRoleById(session.getSpectatorRoleId())); - + for (Map.Entry player : session.getPlayers().entrySet()) { if (Objects.equals(user.getIdLong(), player.getValue().getUserId())) { List deadRoles = player.getValue().getDeadRoles(); @@ -203,18 +235,19 @@ public static boolean playerRevived(Session session, Member user, String roleToR // Revive the role deadRoles.remove(roleToRevive); - + // Update session immediately - Session.fetchCollection().updateOne(eq("guildId", session.getGuildId()), set("players", session.getPlayers())); + 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); + session.addLog(dev.robothanzo.werewolf.database.documents.LogType.PLAYER_REVIVED, + player.getValue().getNickname() + " 的 " + roleToRevive + " 身份已復活", + metadata); // Handle transition from Dead -> Alive if (wasFullyDead) { @@ -243,10 +276,9 @@ public static boolean playerRevived(Session session, Member user, String roleToR if (channel != null) { channel.sendMessage("因為你復活了,所以你的角色變成了 " + currentRoleName).queue(); } - - + // Broadcast updates after all changes including nickname - WerewolfHelper.webServer.broadcastSessionUpdate(session); + WerewolfApplication.gameSessionService.broadcastSessionUpdate(session); return true; } } @@ -256,13 +288,16 @@ public static boolean playerRevived(Session session, Member user, String roleToR public static void selectNewPolice(EntitySelectInteractionEvent event) { if (transferPoliceSessions.containsKey(Objects.requireNonNull(event.getGuild()).getIdLong())) { Member target = event.getMentions().getMembers().getFirst(); - TransferPoliceSession session = transferPoliceSessions.get(Objects.requireNonNull(event.getGuild()).getIdLong()); + TransferPoliceSession session = transferPoliceSessions + .get(Objects.requireNonNull(event.getGuild()).getIdLong()); if (!session.getPossibleRecipientIds().contains(target.getIdLong())) { event.reply(":x: 你不能移交警徽給這個人").setEphemeral(true).queue(); } else { if (session.getSenderId() == event.getUser().getIdLong()) { - Session guildSession = Session.fetchCollection().find(eq("guildId", event.getGuild().getIdLong())).first(); - if (guildSession == null) return; + Session guildSession = Session.fetchCollection().find(eq("guildId", event.getGuild().getIdLong())) + .first(); + if (guildSession == null) + return; for (var player : guildSession.getPlayers().values()) { if (Objects.requireNonNull(player.getUserId()) == target.getIdLong()) { session.setRecipientId(player.getId()); @@ -280,15 +315,19 @@ public static void selectNewPolice(EntitySelectInteractionEvent event) { @dev.robothanzo.jda.interactions.annotations.Button public static void confirmNewPolice(ButtonInteractionEvent event) { if (transferPoliceSessions.containsKey(Objects.requireNonNull(event.getGuild()).getIdLong())) { - TransferPoliceSession session = transferPoliceSessions.get(Objects.requireNonNull(event.getGuild()).getIdLong()); + TransferPoliceSession session = transferPoliceSessions + .get(Objects.requireNonNull(event.getGuild()).getIdLong()); if (session.getSenderId() == event.getUser().getIdLong()) { if (session.getRecipientId() != null) { - Session.fetchCollection().updateOne(eq("guildId", event.getGuild().getIdLong()), set("players." + session.getRecipientId() + ".police", true)); - log.info("Transferred police to " + session.getRecipientId() + " in guild " + event.getGuild().getIdLong()); + Session.fetchCollection().updateOne(eq("guildId", event.getGuild().getIdLong()), + set("players." + session.getRecipientId() + ".police", true)); + log.info("Transferred police to " + session.getRecipientId() + " in guild " + + event.getGuild().getIdLong()); transferPoliceSessions.remove(event.getGuild().getIdLong()); - + // Update Recipient Nickname - Session.Player recipientPlayer = Objects.requireNonNull(CmdUtils.getSession(event)).getPlayers().get(session.getRecipientId().toString()); + Session.Player recipientPlayer = Objects.requireNonNull(CmdUtils.getSession(event)).getPlayers() + .get(session.getRecipientId().toString()); recipientPlayer.setPolice(true); Long recipientDiscordId = recipientPlayer.getUserId(); if (recipientDiscordId != null) { @@ -301,19 +340,31 @@ public static void confirmNewPolice(ButtonInteractionEvent event) { // 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)); + 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) { - // 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); - } + // We need the player object for sender to correctly regenerate name (e.g. if + // they are dead?) + // Usually transfer logic happens when dead, but could be alive transfer. + var senderEntry = Objects.requireNonNull(CmdUtils.getSession(event)).getPlayers().entrySet() + .stream().filter(e -> Objects.equals(e.getValue().getUserId(), session.getSenderId())) + .findFirst(); + if (senderEntry.isPresent()) { + Session.Player senderPlayer = senderEntry.get().getValue(); + senderPlayer.setPolice(false); + senderPlayer.updateNickname(sender); + } } - if (session.getCallback() != null) session.getCallback().run(); + if (session.getCallback() != null) + session.getCallback().run(); } else { event.reply(":x: 請先選擇要移交警徽的對象").setEphemeral(true).queue(); } @@ -326,11 +377,13 @@ public static void confirmNewPolice(ButtonInteractionEvent event) { @dev.robothanzo.jda.interactions.annotations.Button public static void destroyPolice(ButtonInteractionEvent event) { if (transferPoliceSessions.containsKey(Objects.requireNonNull(event.getGuild()).getIdLong())) { - TransferPoliceSession session = transferPoliceSessions.get(Objects.requireNonNull(event.getGuild()).getIdLong()); + TransferPoliceSession session = transferPoliceSessions + .get(Objects.requireNonNull(event.getGuild()).getIdLong()); if (session.getSenderId() == event.getUser().getIdLong()) { transferPoliceSessions.remove(event.getGuild().getIdLong()); event.reply(":white_check_mark: 警徽已撕毀").setEphemeral(false).queue(); - if (session.getCallback() != null) session.getCallback().run(); + if (session.getCallback() != null) + session.getCallback().run(); } else { event.reply(":x: 你不是原本的警長").setEphemeral(true).queue(); } @@ -339,45 +392,53 @@ public static void destroyPolice(ButtonInteractionEvent event) { @dev.robothanzo.jda.interactions.annotations.Button public void changeRoleOrder(ButtonInteractionEvent event) { + if (event.getGuild() == null) + return; + event.deferReply().queue(); Session session = CmdUtils.getSession(event); - if (session == null) return; + if (session == null) + return; + for (Session.Player player : session.getPlayers().values()) { if (Objects.equals(event.getUser().getIdLong(), player.getUserId())) { - assert player.getRoles() != null; - if (player.isRolePositionLocked()) { - event.getHook().editOriginal(":x: 你的身分順序已被鎖定").queue(); - return; + try { + WerewolfApplication.playerService.switchRoleOrder(event.getGuild().getIdLong(), + String.valueOf(player.getId())); + event.getHook().editOriginal(":white_check_mark: 交換成功").queue(); + } catch (Exception e) { + event.getHook().editOriginal(":x: " + e.getMessage()).queue(); } - Collections.reverse(player.getRoles()); - event.reply(":white_check_mark: 你目前的順序: " + String.join("、", player.getRoles())).queue(); - Session.fetchCollection().updateOne(eq("guildId", Objects.requireNonNull(event.getGuild()).getIdLong()), - set("players", session.getPlayers())); - WerewolfHelper.webServer.broadcastSessionUpdate(session); return; } } - event.reply(":x:").queue(); + event.getHook().editOriginal(":x: 你不是玩家").queue(); } @Subcommand(description = "升官為法官") public void judge(SlashCommandInteractionEvent event, @Option(value = "user") User user) { event.deferReply().queue(); - if (!CmdUtils.isAdmin(event)) return; + if (!CmdUtils.isAdmin(event)) + return; Session session = CmdUtils.getSession(event); - if (session == null) return; + if (session == null) + return; Objects.requireNonNull(event.getGuild()).addRoleToMember( - Objects.requireNonNull(event.getGuild().getMemberById(user.getId())), Objects.requireNonNull(event.getGuild().getRoleById(session.getJudgeRoleId()))).queue(); + Objects.requireNonNull(event.getGuild().getMemberById(user.getId())), + Objects.requireNonNull(event.getGuild().getRoleById(session.getJudgeRoleId()))).queue(); event.getHook().editOriginal(":white_check_mark:").queue(); } @Subcommand(description = "貶官為庶民") public void demote(SlashCommandInteractionEvent event, @Option(value = "user") User user) { event.deferReply().queue(); - if (!CmdUtils.isAdmin(event)) return; + if (!CmdUtils.isAdmin(event)) + return; Session session = CmdUtils.getSession(event); - if (session == null) return; + if (session == null) + return; Objects.requireNonNull(event.getGuild()).removeRoleFromMember( - Objects.requireNonNull(event.getGuild().getMemberById(user.getId())), Objects.requireNonNull(event.getGuild().getRoleById(session.getJudgeRoleId()))).queue(); + Objects.requireNonNull(event.getGuild().getMemberById(user.getId())), + Objects.requireNonNull(event.getGuild().getRoleById(session.getJudgeRoleId()))).queue(); event.getHook().editOriginal(":white_check_mark:").queue(); } @@ -385,10 +446,13 @@ public void demote(SlashCommandInteractionEvent event, @Option(value = "user") U public void died(SlashCommandInteractionEvent event, @Option(value = "user", description = "死掉的使用者") User user, @Option(value = "last_words", description = "是否讓他講遺言 (預設為否) (若為雙身分,只會在兩張牌都死掉的時候啟動)", optional = true) Boolean lastWords) { event.deferReply().queue(); - if (!CmdUtils.isAdmin(event)) return; + if (!CmdUtils.isAdmin(event)) + return; Session session = CmdUtils.getSession(event); - if (session == null) return; - if (lastWords == null) lastWords = false; + if (session == null) + return; + if (lastWords == null) + lastWords = false; Member member = Objects.requireNonNull(Objects.requireNonNull(event.getGuild()).getMemberById(user.getId())); if (playerDied(session, member, lastWords, false)) { @@ -401,28 +465,31 @@ public void died(SlashCommandInteractionEvent event, @Option(value = "user", des @Subcommand(description = "指派玩家編號並傳送身分") public void assign(SlashCommandInteractionEvent event) { event.deferReply().queue(); - if (!CmdUtils.isAdmin(event)) return; + if (!CmdUtils.isAdmin(event)) + return; Session session = CmdUtils.getSession(event); - if (session == null) return; + if (session == null) + return; try { - dev.robothanzo.werewolf.server.SessionAPI.assignRoles(event.getGuild().getIdLong(), event.getJDA(), - msg -> log.info("[Assign] " + msg), - p -> {} - ); + WerewolfApplication.roleService.assignRoles(event.getGuild().getIdLong(), + msg -> log.info("[Assign] " + msg), + p -> { + }); event.getHook().editOriginal(":white_check_mark: 身分分配完成!").queue(); } catch (Exception e) { event.getHook().editOriginal(":x: " + e.getMessage()).queue(); } } - @Subcommand(description = "列出每個玩家的身分資訊") public void roles(SlashCommandInteractionEvent event) { event.deferReply().queue(); - if (!CmdUtils.isAdmin(event)) return; + if (!CmdUtils.isAdmin(event)) + return; Session session = CmdUtils.getSession(event); - if (session == null) return; + if (session == null) + return; EmbedBuilder embedBuilder = new EmbedBuilder() .setTitle("身分列表") .setColor(MsgUtils.getRandomColor()); @@ -431,34 +498,27 @@ public void roles(SlashCommandInteractionEvent event) { assert player.getRoles() != null; embedBuilder.addField(player.getNickname(), String.join("、", player.getRoles()) + (player.isPolice() ? " (警長)" : "") + - (player.isJinBaoBao() ? " (金寶寶)" : player.isDuplicated() ? " (複製人)" : ""), true); + (player.isJinBaoBao() ? " (金寶寶)" : player.isDuplicated() ? " (複製人)" : ""), + true); } event.getHook().editOriginalEmbeds(embedBuilder.build()).queue(); } @Subcommand(description = "強制某人成為警長 (將會清除舊的警長)") - public void force_police(SlashCommandInteractionEvent - event, @Option(value = "user", description = "要強制成為警長的玩家") User user) { + public void force_police(SlashCommandInteractionEvent event, + @Option(value = "user", description = "要強制成為警長的玩家") User user) { event.deferReply().queue(); - if (!CmdUtils.isAdmin(event)) return; - if (event.getGuild() == null) return; - Session session = CmdUtils.getSession(event); - if (session == null) return; - for (Session.Player player : session.getPlayers().values()) { - if (player.isPolice() && !Objects.equals(player.getUserId(), user.getIdLong())) { - player.setPolice(false); - Session.fetchCollection().updateOne(eq("guildId", session.getGuildId()), set("players", session.getPlayers())); - Member member = event.getGuild().getMemberById(Objects.requireNonNull(player.getUserId())); - if (member != null) 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) player.updateNickname(member); - } + if (!CmdUtils.isAdmin(event)) + return; + if (event.getGuild() == null) + return; + + try { + WerewolfApplication.gameActionService.setPolice(event.getGuild().getIdLong(), user.getIdLong()); + event.getHook().editOriginal(":white_check_mark:").queue(); + } catch (Exception e) { + event.getHook().editOriginal(":x: " + e.getMessage()).queue(); } - event.getHook().editOriginal(":white_check_mark:").queue(); } @Data diff --git a/src/main/java/dev/robothanzo/werewolf/commands/Poll.java b/src/main/java/dev/robothanzo/werewolf/commands/Poll.java index ae31f7a..fc6b5b4 100644 --- a/src/main/java/dev/robothanzo/werewolf/commands/Poll.java +++ b/src/main/java/dev/robothanzo/werewolf/commands/Poll.java @@ -2,91 +2,102 @@ import dev.robothanzo.jda.interactions.annotations.slash.Command; import dev.robothanzo.jda.interactions.annotations.slash.Subcommand; -import dev.robothanzo.werewolf.WerewolfHelper; +import dev.robothanzo.werewolf.WerewolfApplication; import dev.robothanzo.werewolf.audio.Audio; import dev.robothanzo.werewolf.database.documents.Session; -import dev.robothanzo.werewolf.server.WebServer; +import dev.robothanzo.werewolf.model.Candidate; import dev.robothanzo.werewolf.utils.CmdUtils; import dev.robothanzo.werewolf.utils.MsgUtils; -import lombok.Builder; -import lombok.Data; +import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; -import net.dv8tion.jda.api.components.actionrow.ActionRow; -import net.dv8tion.jda.api.components.buttons.Button; -import org.jetbrains.annotations.Nullable; -import java.util.*; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; -import static com.mongodb.client.model.Filters.eq; -import static com.mongodb.client.model.Updates.set; - @Command +@Slf4j public class Poll { - public static Map> expelCandidates = new ConcurrentHashMap<>(); // key is guild id // second key is candidate id + public static Map> expelCandidates = new ConcurrentHashMap<>(); // key is guild id // + // second key is + // candidate id - public static void handleExpelPK(Session session, GuildMessageChannel channel, Message message, List winners) { - message.reply("平票,請PK").queue(); + public static void handleExpelPK(Session session, GuildMessageChannel channel, Message message, + List winners) { + if (message != null) + message.reply("平票,請PK").queue(); Map newCandidates = new ConcurrentHashMap<>(); for (Candidate winner : winners) { - winner.electors.clear(); + winner.getElectors().clear(); winner.setExpelPK(true); newCandidates.put(winner.getPlayer().getId(), winner); } expelCandidates.put(channel.getGuild().getIdLong(), newCandidates); - Speech.pollSpeech(channel.getGuild(), message, newCandidates.values().stream().map(Candidate::getPlayer).toList(), + WerewolfApplication.speechService.startSpeechPoll(channel.getGuild(), message, + newCandidates.values().stream().map(Candidate::getPlayer).toList(), () -> startExpelPoll(session, channel, false)); } public static void startExpelPoll(Session session, GuildMessageChannel channel, boolean allowPK) { Audio.play(Audio.Resource.EXPEL_POLL, channel.getGuild().getVoiceChannelById(session.getCourtVoiceChannelId())); - EmbedBuilder embedBuilder = new EmbedBuilder().setTitle("驅逐投票").setDescription("30秒後立刻計票,請加快手速!\n若要改票可直接按下要改成的對象\n若要改為棄票需按下原本投給的使用者").setColor(MsgUtils.getRandomColor()); + EmbedBuilder embedBuilder = new EmbedBuilder().setTitle("驅逐投票") + .setDescription("30秒後立刻計票,請加快手速!\n若要改票可直接按下要改成的對象\n若要改為棄票需按下原本投給的使用者") + .setColor(MsgUtils.getRandomColor()); List