Dashboard real-time updates con SSE y tabla de rooms
- Implementación de Server-Sent Events (SSE) para actualizaciones en tiempo real - Nuevo componente RoomsTable para vista de águila con animaciones de tokens - Componente RoomCard extraído para reutilización - Modal para ver detalles de rooms - SystemMessageDisplay usando AnimatedNumber creativamente - Indicador de conexión SSE con fallback a polling - Colores dinámicos de texto basados en brillo del fondo - Backend: broadcast de actualizaciones del dashboard - Backend: mensajes del sistema (excepto cambios de ronda) visibles en dashboard - Configuración de Vite para acceso desde red local
This commit is contained in:
353
client/src/components/RoomCard.vue
Normal file
353
client/src/components/RoomCard.vue
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
<template>
|
||||||
|
<div class="room-card">
|
||||||
|
<div class="room-header">
|
||||||
|
<span class="room-id">Room {{ room.roomId.slice(0, 8) }}</span>
|
||||||
|
<span class="room-status" :class="`status-${room.metadata?.gameStatus || 'waiting'}`">
|
||||||
|
{{ room.metadata?.gameStatus || 'waiting' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="room-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Players:</span>
|
||||||
|
<span class="detail-value">{{ room.clients }}/{{ room.maxClients }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Round:</span>
|
||||||
|
<span class="detail-value">{{ room.metadata?.currentRound || 1 }}/3</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Game Variant:</span>
|
||||||
|
<span class="detail-value">{{ room.metadata?.currentVariant || 'G1' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Created:</span>
|
||||||
|
<span class="detail-value">{{ formatTime(room.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="room-actions">
|
||||||
|
<button
|
||||||
|
v-if="room.metadata?.gameStatus === 'playing'"
|
||||||
|
@click="$emit('pause', room.roomId)"
|
||||||
|
class="btn btn-action btn-pause"
|
||||||
|
>
|
||||||
|
⏸️ Pause
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="room.metadata?.gameStatus === 'paused'"
|
||||||
|
@click="$emit('resume', room.roomId)"
|
||||||
|
class="btn btn-action btn-resume"
|
||||||
|
>
|
||||||
|
▶️ Resume
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="$emit('restart', room.roomId)"
|
||||||
|
class="btn btn-action btn-restart"
|
||||||
|
>
|
||||||
|
🔄 Restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="$emit('viewDetails', room.roomId)"
|
||||||
|
class="btn btn-action btn-view"
|
||||||
|
>
|
||||||
|
📊 Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="roomDetails" class="room-stats">
|
||||||
|
<h4>Room Statistics</h4>
|
||||||
|
<div v-if="roomDetails.players" class="players-list">
|
||||||
|
<div v-for="player in roomDetails.players"
|
||||||
|
:key="player.sessionId"
|
||||||
|
class="player-row">
|
||||||
|
<div class="player-info">
|
||||||
|
<span class="player-name">{{ player.name }}</span>
|
||||||
|
<span class="player-role">{{ player.role }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-tokens">
|
||||||
|
<span class="token pavo">🦃 {{ player.pavoTokens || 0 }}</span>
|
||||||
|
<span class="token elote">🌽 {{ player.eloteTokens || 0 }}</span>
|
||||||
|
<span class="token shame">😳 {{ player.shameTokens || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="$emit('kickPlayer', room.roomId, player.sessionId)"
|
||||||
|
class="btn btn-kick"
|
||||||
|
>
|
||||||
|
Kick
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-info">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span>Time Remaining:</span>
|
||||||
|
<span>{{ formatSeconds(roomDetails.timeRemaining) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="roomDetails.winner" class="stat-item">
|
||||||
|
<span>Winner:</span>
|
||||||
|
<span class="winner-name">{{ roomDetails.winner }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Room {
|
||||||
|
roomId: string;
|
||||||
|
clients: number;
|
||||||
|
maxClients: number;
|
||||||
|
createdAt: number;
|
||||||
|
metadata?: {
|
||||||
|
gameStatus?: string;
|
||||||
|
currentRound?: number;
|
||||||
|
currentVariant?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomDetails {
|
||||||
|
players?: Array<{
|
||||||
|
sessionId: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
pavoTokens?: number;
|
||||||
|
eloteTokens?: number;
|
||||||
|
shameTokens?: number;
|
||||||
|
}>;
|
||||||
|
timeRemaining: number;
|
||||||
|
winner?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
room: Room;
|
||||||
|
roomDetails?: RoomDetails;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
pause: [roomId: string];
|
||||||
|
resume: [roomId: string];
|
||||||
|
restart: [roomId: string];
|
||||||
|
viewDetails: [roomId: string];
|
||||||
|
kickPlayer: [roomId: string, playerId: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function formatTime(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.room-card {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 2px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-id {
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-status {
|
||||||
|
padding: 5px 15px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-waiting {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-playing {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-paused {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-finished {
|
||||||
|
background: #f3e5f5;
|
||||||
|
color: #9c27b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-details {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pause {
|
||||||
|
background: #ff9800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-resume {
|
||||||
|
background: #4caf50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-restart {
|
||||||
|
background: #2196f3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-view {
|
||||||
|
background: #9c27b0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-kick {
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-stats {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 2px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-stats h4 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players-list {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-role {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-tokens {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token {
|
||||||
|
background: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.pavo {
|
||||||
|
color: #d84315;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.elote {
|
||||||
|
color: #f57f17;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.shame {
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-info {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-name {
|
||||||
|
color: #4caf50;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
203
client/src/components/RoomModal.vue
Normal file
203
client/src/components/RoomModal.vue
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="isOpen" class="modal-overlay" @click="closeModal">
|
||||||
|
<div class="modal-container" @click.stop>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Room Details</h3>
|
||||||
|
<button @click="closeModal" class="btn-close">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-content">
|
||||||
|
<RoomCard
|
||||||
|
v-if="room"
|
||||||
|
:room="room"
|
||||||
|
:room-details="roomDetails"
|
||||||
|
@pause="handlePause"
|
||||||
|
@resume="handleResume"
|
||||||
|
@restart="handleRestart"
|
||||||
|
@view-details="handleViewDetails"
|
||||||
|
@kick-player="handleKickPlayer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import RoomCard from './RoomCard.vue';
|
||||||
|
|
||||||
|
interface Room {
|
||||||
|
roomId: string;
|
||||||
|
clients: number;
|
||||||
|
maxClients: number;
|
||||||
|
createdAt: number;
|
||||||
|
metadata?: {
|
||||||
|
gameStatus?: string;
|
||||||
|
currentRound?: number;
|
||||||
|
currentVariant?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomDetails {
|
||||||
|
players?: Array<{
|
||||||
|
sessionId: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
pavoTokens?: number;
|
||||||
|
eloteTokens?: number;
|
||||||
|
shameTokens?: number;
|
||||||
|
}>;
|
||||||
|
timeRemaining: number;
|
||||||
|
winner?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean;
|
||||||
|
room?: Room;
|
||||||
|
roomDetails?: RoomDetails;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
pause: [roomId: string];
|
||||||
|
resume: [roomId: string];
|
||||||
|
restart: [roomId: string];
|
||||||
|
viewDetails: [roomId: string];
|
||||||
|
kickPlayer: [roomId: string, playerId: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePause(roomId: string) {
|
||||||
|
emit('pause', roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResume(roomId: string) {
|
||||||
|
emit('resume', roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRestart(roomId: string) {
|
||||||
|
emit('restart', roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewDetails(roomId: string) {
|
||||||
|
emit('viewDetails', roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKickPlayer(roomId: string, playerId: string) {
|
||||||
|
emit('kickPlayer', roomId, playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on Escape key
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && props.isOpen) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 20px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: modalSlideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-30px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override RoomCard styles for modal context */
|
||||||
|
.modal-content :deep(.room-card) {
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0 0 16px 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-overlay {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
max-height: 95vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
467
client/src/components/RoomsTable.vue
Normal file
467
client/src/components/RoomsTable.vue
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rooms-table-container">
|
||||||
|
<div class="table-header">
|
||||||
|
<h3>🎮 Game Rooms Overview</h3>
|
||||||
|
<div class="table-controls">
|
||||||
|
<button @click="$emit('refresh')" class="btn btn-refresh">
|
||||||
|
🔄 Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rooms.length === 0" class="no-rooms">
|
||||||
|
No active game rooms
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-wrapper">
|
||||||
|
<table class="rooms-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Room ID</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Players</th>
|
||||||
|
<th>Round</th>
|
||||||
|
<th>Variant</th>
|
||||||
|
<th>P1 Tokens</th>
|
||||||
|
<th>P2 Tokens</th>
|
||||||
|
<th>System Message</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="room in rooms" :key="room.roomId" class="room-row">
|
||||||
|
<td class="room-id">
|
||||||
|
<code>{{ room.roomId.slice(0, 8) }}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge" :class="`status-${room.metadata?.gameStatus || 'waiting'}`">
|
||||||
|
{{ room.metadata?.gameStatus || 'waiting' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="players-count">
|
||||||
|
{{ room.clients }}/{{ room.maxClients }}
|
||||||
|
</td>
|
||||||
|
<td class="round-info">
|
||||||
|
{{ room.metadata?.currentRound || 1 }}/3
|
||||||
|
</td>
|
||||||
|
<td class="variant-info">
|
||||||
|
<span class="variant-badge">{{ room.metadata?.currentVariant || 'G1' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="tokens-cell">
|
||||||
|
<div v-if="getRoomDetails(room.roomId)?.players?.[0]" class="player-section">
|
||||||
|
<div class="player-name-chip" :style="{
|
||||||
|
backgroundColor: getPlayerColor(getRoomDetails(room.roomId).players[0], 0),
|
||||||
|
color: getReadableTextColor(getPlayerColor(getRoomDetails(room.roomId).players[0], 0))
|
||||||
|
}">
|
||||||
|
{{ getRoomDetails(room.roomId).players[0].name }}
|
||||||
|
</div>
|
||||||
|
<div class="token-summary">
|
||||||
|
<span class="token pavo">🦃 <AnimatedNumber :value="getRoomDetails(room.roomId).players[0].pavoTokens || 0" :duration-ms="800" /></span>
|
||||||
|
<span class="token elote">🌽 <AnimatedNumber :value="getRoomDetails(room.roomId).players[0].eloteTokens || 0" :duration-ms="800" /></span>
|
||||||
|
<span class="token shame">😳 <AnimatedNumber :value="getRoomDetails(room.roomId).players[0].shameTokens || 0" :duration-ms="800" /></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="no-data">-</div>
|
||||||
|
</td>
|
||||||
|
<td class="tokens-cell">
|
||||||
|
<div v-if="getRoomDetails(room.roomId)?.players?.[1]" class="player-section">
|
||||||
|
<div class="player-name-chip" :style="{
|
||||||
|
backgroundColor: getPlayerColor(getRoomDetails(room.roomId).players[1], 1),
|
||||||
|
color: getReadableTextColor(getPlayerColor(getRoomDetails(room.roomId).players[1], 1))
|
||||||
|
}">
|
||||||
|
{{ getRoomDetails(room.roomId).players[1].name }}
|
||||||
|
</div>
|
||||||
|
<div class="token-summary">
|
||||||
|
<span class="token pavo">🦃 <AnimatedNumber :value="getRoomDetails(room.roomId).players[1].pavoTokens || 0" :duration-ms="800" /></span>
|
||||||
|
<span class="token elote">🌽 <AnimatedNumber :value="getRoomDetails(room.roomId).players[1].eloteTokens || 0" :duration-ms="800" /></span>
|
||||||
|
<span class="token shame">😳 <AnimatedNumber :value="getRoomDetails(room.roomId).players[1].shameTokens || 0" :duration-ms="800" /></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="no-data">-</div>
|
||||||
|
</td>
|
||||||
|
<td class="system-message-cell">
|
||||||
|
<SystemMessageDisplay
|
||||||
|
:message="getRoomDetails(room.roomId)?.recentSystemMessage"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="created-time">
|
||||||
|
{{ formatTime(room.createdAt) }}
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button
|
||||||
|
@click="$emit('viewRoomModal', room.roomId)"
|
||||||
|
class="btn btn-details"
|
||||||
|
title="View detailed card"
|
||||||
|
>
|
||||||
|
📊 Details
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AnimatedNumber from '../views/games/AnimatedNumber.vue';
|
||||||
|
import SystemMessageDisplay from './SystemMessageDisplay.vue';
|
||||||
|
interface Room {
|
||||||
|
roomId: string;
|
||||||
|
clients: number;
|
||||||
|
maxClients: number;
|
||||||
|
createdAt: number;
|
||||||
|
metadata?: {
|
||||||
|
gameStatus?: string;
|
||||||
|
currentRound?: number;
|
||||||
|
currentVariant?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomDetails {
|
||||||
|
players?: Array<{
|
||||||
|
sessionId: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
pavoTokens?: number;
|
||||||
|
eloteTokens?: number;
|
||||||
|
shameTokens?: number;
|
||||||
|
}>;
|
||||||
|
timeRemaining: number;
|
||||||
|
winner?: string;
|
||||||
|
recentSystemMessage?: {
|
||||||
|
text: string;
|
||||||
|
kind: string;
|
||||||
|
timestamp: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rooms: Room[];
|
||||||
|
roomDetails: { [key: string]: RoomDetails };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
refresh: [];
|
||||||
|
viewRoomModal: [roomId: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function getRoomDetails(roomId: string) {
|
||||||
|
return props.roomDetails[roomId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerColor(player: any, playerIndex: number): string {
|
||||||
|
// Use player's actual color if available
|
||||||
|
if (player?.color) {
|
||||||
|
return player.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback colors for P1 and P2
|
||||||
|
const fallbackColors = ['#667eea', '#f093fb'];
|
||||||
|
return fallbackColors[playerIndex] || '#999';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadableTextColor(hex?: string): string {
|
||||||
|
const c = (hex || '').trim();
|
||||||
|
const m = c.match(/^#?([a-fA-F0-9]{6})$/);
|
||||||
|
let r=102, g=126, b=234; // fallback to #667eea
|
||||||
|
if (m) {
|
||||||
|
const int = parseInt(m[1], 16);
|
||||||
|
r = (int >> 16) & 255; g = (int >> 8) & 255; b = int & 255;
|
||||||
|
}
|
||||||
|
// Perceived brightness formula
|
||||||
|
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
return yiq >= 140 ? '#111111' : '#ffffff';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rooms-table-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 2px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-details {
|
||||||
|
background: #9c27b0;
|
||||||
|
color: white;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-details:hover {
|
||||||
|
background: #7b1fa2;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-rooms {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms-table th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms-table td {
|
||||||
|
padding: 12px 8px;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-row:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-id code {
|
||||||
|
background: #e9ecef;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-waiting {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-playing {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-paused {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-finished {
|
||||||
|
background: #f3e5f5;
|
||||||
|
color: #9c27b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players-count {
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-info {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-badge {
|
||||||
|
background: #e1f5fe;
|
||||||
|
color: #0277bd;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokens-cell {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-name-chip {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token :deep(.anim-number) {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
height: auto;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token :deep(.viewport) {
|
||||||
|
height: auto;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token :deep(.val) {
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.pavo {
|
||||||
|
color: #d84315;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.elote {
|
||||||
|
color: #f57f17;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.shame {
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.created-time {
|
||||||
|
color: #555;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-message-cell {
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 200px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.rooms-table {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms-table th,
|
||||||
|
.rooms-table td {
|
||||||
|
padding: 8px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-summary {
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token :deep(.anim-number) {
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-message-cell {
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
150
client/src/components/SystemMessageDisplay.vue
Normal file
150
client/src/components/SystemMessageDisplay.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div class="system-message-container">
|
||||||
|
<AnimatedNumber
|
||||||
|
:value="messageCounter"
|
||||||
|
:duration-ms="600"
|
||||||
|
@value-change="onMessageChange"
|
||||||
|
/>
|
||||||
|
<div v-if="currentMessage" :class="['system-message', `kind-${currentMessage.kind}`]">
|
||||||
|
{{ currentMessage.text }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="system-message no-message">
|
||||||
|
Waiting for activity...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import AnimatedNumber from '../views/games/AnimatedNumber.vue';
|
||||||
|
|
||||||
|
interface SystemMessage {
|
||||||
|
text: string;
|
||||||
|
kind: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
message?: SystemMessage | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Use a counter that increments when message changes to trigger animation
|
||||||
|
const messageCounter = ref(0);
|
||||||
|
const currentMessage = ref<SystemMessage | null>(null);
|
||||||
|
|
||||||
|
// Watch for message changes
|
||||||
|
watch(() => props.message, (newMessage) => {
|
||||||
|
if (newMessage && newMessage.timestamp !== currentMessage.value?.timestamp) {
|
||||||
|
currentMessage.value = newMessage;
|
||||||
|
messageCounter.value++; // This triggers the AnimatedNumber animation
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
function onMessageChange() {
|
||||||
|
// This fires when the AnimatedNumber animation completes
|
||||||
|
// Could add additional effects here
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.system-message-container {
|
||||||
|
position: relative;
|
||||||
|
min-height: 24px;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-message-container :deep(.anim-number) {
|
||||||
|
position: absolute;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-message {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
animation: messageSlideIn 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes messageSlideIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px) scale(0.95);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Different styles for different message kinds */
|
||||||
|
.kind-p2_accept {
|
||||||
|
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
|
||||||
|
border-color: #28a745;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-p2_reject {
|
||||||
|
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
|
||||||
|
border-color: #ffc107;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-p2_snatch {
|
||||||
|
background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%);
|
||||||
|
border-color: #dc3545;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-p1_propose {
|
||||||
|
background: linear-gradient(135deg, #cce5ff 0%, #b3d9ff 100%);
|
||||||
|
border-color: #007bff;
|
||||||
|
color: #004085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-p1_no_offer {
|
||||||
|
background: linear-gradient(135deg, #e2e3e5 0%, #d1ecf1 100%);
|
||||||
|
border-color: #6c757d;
|
||||||
|
color: #383d41;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-p1_shame {
|
||||||
|
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
|
||||||
|
border-color: #ffc107;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-p1_report {
|
||||||
|
background: linear-gradient(135deg, #e7e3ff 0%, #d1c4e9 100%);
|
||||||
|
border-color: #9c27b0;
|
||||||
|
color: #4a148c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-variant_change {
|
||||||
|
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
|
||||||
|
border-color: #03a9f4;
|
||||||
|
color: #01579b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-round_advance {
|
||||||
|
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
|
||||||
|
border-color: #4caf50;
|
||||||
|
color: #1b5e20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-message {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-color: #dee2e6;
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<div class="dashboard-header">
|
<div class="dashboard-header">
|
||||||
<h1>🎛️ Admin Dashboard</h1>
|
<div class="header-top">
|
||||||
|
<h1>🎛️ Admin Dashboard</h1>
|
||||||
|
<div class="connection-status">
|
||||||
|
<div :class="['status-indicator', { 'connected': isSSEConnected, 'disconnected': !isSSEConnected }]"></div>
|
||||||
|
<span class="status-text">{{ isSSEConnected ? 'Real-time' : 'Polling' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="stats-summary">
|
<div class="stats-summary">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span class="stat-label">Total CCU</span>
|
<span class="stat-label">Total CCU</span>
|
||||||
@@ -20,87 +26,50 @@
|
|||||||
|
|
||||||
<div class="dashboard-content">
|
<div class="dashboard-content">
|
||||||
<div class="rooms-section">
|
<div class="rooms-section">
|
||||||
<h2>Active Game Rooms</h2>
|
<div class="section-header">
|
||||||
|
<h2>Active Game Rooms</h2>
|
||||||
|
<div class="view-controls">
|
||||||
|
<button
|
||||||
|
@click="viewMode = 'table'"
|
||||||
|
:class="['btn', 'btn-view-mode', { active: viewMode === 'table' }]"
|
||||||
|
>
|
||||||
|
📊 Table View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="viewMode = 'cards'"
|
||||||
|
:class="['btn', 'btn-view-mode', { active: viewMode === 'cards' }]"
|
||||||
|
>
|
||||||
|
🎴 Cards View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="rooms.length === 0" class="no-rooms">
|
<div v-if="rooms.length === 0" class="no-rooms">
|
||||||
No active game rooms
|
No active game rooms
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="rooms-grid">
|
|
||||||
<div v-for="room in gameRooms" :key="room.roomId" class="room-card">
|
<!-- Table View -->
|
||||||
<div class="room-header">
|
<RoomsTable
|
||||||
<span class="room-id">Room {{ room.roomId.slice(0, 8) }}</span>
|
v-if="viewMode === 'table' && gameRooms.length > 0"
|
||||||
<span class="room-status" :class="`status-${room.metadata?.gameStatus || 'waiting'}`">
|
:rooms="gameRooms"
|
||||||
{{ room.metadata?.gameStatus || 'waiting' }}
|
:room-details="roomDetails"
|
||||||
</span>
|
@refresh="fetchData"
|
||||||
</div>
|
@view-room-modal="openRoomModal"
|
||||||
|
/>
|
||||||
<div class="room-details">
|
|
||||||
<div class="detail-row">
|
<!-- Cards View -->
|
||||||
<span class="detail-label">Players:</span>
|
<div v-else-if="viewMode === 'cards' && gameRooms.length > 0" class="rooms-grid">
|
||||||
<span class="detail-value">{{ room.clients }}/{{ room.maxClients }}</span>
|
<RoomCard
|
||||||
</div>
|
v-for="room in gameRooms"
|
||||||
<div class="detail-row">
|
:key="room.roomId"
|
||||||
<span class="detail-label">Created:</span>
|
:room="room"
|
||||||
<span class="detail-value">{{ formatTime(room.createdAt) }}</span>
|
:room-details="roomDetails[room.roomId]"
|
||||||
</div>
|
@pause="pauseRoom"
|
||||||
</div>
|
@resume="resumeRoom"
|
||||||
|
@restart="restartRoom"
|
||||||
<div class="room-actions">
|
@view-details="viewRoomDetails"
|
||||||
<button
|
@kick-player="kickPlayer"
|
||||||
v-if="room.metadata?.gameStatus === 'playing'"
|
/>
|
||||||
@click="pauseRoom(room.roomId)"
|
|
||||||
class="btn btn-action btn-pause"
|
|
||||||
>
|
|
||||||
⏸️ Pause
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="room.metadata?.gameStatus === 'paused'"
|
|
||||||
@click="resumeRoom(room.roomId)"
|
|
||||||
class="btn btn-action btn-resume"
|
|
||||||
>
|
|
||||||
▶️ Resume
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="restartRoom(room.roomId)"
|
|
||||||
class="btn btn-action btn-restart"
|
|
||||||
>
|
|
||||||
🔄 Restart
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="viewRoomDetails(room.roomId)"
|
|
||||||
class="btn btn-action btn-view"
|
|
||||||
>
|
|
||||||
📊 Details
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="roomDetails[room.roomId]" class="room-stats">
|
|
||||||
<h4>Room Statistics</h4>
|
|
||||||
<div v-if="roomDetails[room.roomId].players" class="players-list">
|
|
||||||
<div v-for="player in roomDetails[room.roomId].players"
|
|
||||||
:key="player.sessionId"
|
|
||||||
class="player-row">
|
|
||||||
<span class="player-name">{{ player.name }}</span>
|
|
||||||
<span class="player-clicks">{{ player.clicks }} clicks</span>
|
|
||||||
<button
|
|
||||||
@click="kickPlayer(room.roomId, player.sessionId)"
|
|
||||||
class="btn btn-kick"
|
|
||||||
>
|
|
||||||
Kick
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-info">
|
|
||||||
<div class="stat-item">
|
|
||||||
<span>Time Remaining:</span>
|
|
||||||
<span>{{ formatSeconds(roomDetails[room.roomId].timeRemaining) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="roomDetails[room.roomId].winner" class="stat-item">
|
|
||||||
<span>Winner:</span>
|
|
||||||
<span class="winner-name">{{ roomDetails[room.roomId].winner }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,6 +98,19 @@
|
|||||||
🎮 Go to Lobby
|
🎮 Go to Lobby
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Room Details Modal -->
|
||||||
|
<RoomModal
|
||||||
|
:is-open="isModalOpen"
|
||||||
|
:room="selectedRoom"
|
||||||
|
:room-details="roomDetails[selectedRoomId]"
|
||||||
|
@close="closeRoomModal"
|
||||||
|
@pause="pauseRoom"
|
||||||
|
@resume="resumeRoom"
|
||||||
|
@restart="restartRoom"
|
||||||
|
@view-details="viewRoomDetails"
|
||||||
|
@kick-player="kickPlayer"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -136,26 +118,34 @@
|
|||||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { colyseusService } from '../services/colyseus';
|
import { colyseusService } from '../services/colyseus';
|
||||||
|
import RoomCard from '../components/RoomCard.vue';
|
||||||
|
import RoomsTable from '../components/RoomsTable.vue';
|
||||||
|
import RoomModal from '../components/RoomModal.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const rooms = ref<any[]>([]);
|
const rooms = ref<any[]>([]);
|
||||||
const roomDetails = ref<{ [key: string]: any }>({});
|
const roomDetails = ref<{ [key: string]: any }>({});
|
||||||
const globalStats = ref<any>(null);
|
const globalStats = ref<any>(null);
|
||||||
const refreshInterval = ref<NodeJS.Timeout>();
|
const refreshInterval = ref<NodeJS.Timeout>();
|
||||||
|
const selectedRoomId = ref<string>('');
|
||||||
|
const isModalOpen = ref(false);
|
||||||
|
const viewMode = ref<'cards' | 'table'>('table');
|
||||||
|
const eventSource = ref<EventSource | null>(null);
|
||||||
|
const isSSEConnected = ref(false);
|
||||||
|
const reconnectAttempts = ref(0);
|
||||||
|
const maxReconnectAttempts = 5;
|
||||||
|
|
||||||
const gameRooms = computed(() => rooms.value.filter(r => r.name === 'game'));
|
const gameRooms = computed(() => rooms.value.filter(r => r.name === 'game'));
|
||||||
const lobbyRooms = computed(() => rooms.value.filter(r => r.name === 'lobby'));
|
const lobbyRooms = computed(() => rooms.value.filter(r => r.name === 'lobby'));
|
||||||
const totalPlayers = computed(() => rooms.value.reduce((sum, room) => sum + room.clients, 0));
|
const totalPlayers = computed(() => rooms.value.reduce((sum, room) => sum + room.clients, 0));
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData();
|
// Try SSE first, fallback to polling if it fails
|
||||||
refreshInterval.value = setInterval(fetchData, 3000);
|
initSSE();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (refreshInterval.value) {
|
cleanup();
|
||||||
clearInterval(refreshInterval.value);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
@@ -173,7 +163,15 @@ async function fetchData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function viewRoomDetails(roomId: string) {
|
async function viewRoomDetails(roomId: string) {
|
||||||
|
// If we have SSE connection, details are already coming in real-time
|
||||||
|
if (isSSEConnected.value && roomDetails.value[roomId]) {
|
||||||
|
console.log('[Dashboard] Room details already available via SSE');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to fetch if SSE is not connected or details are missing
|
||||||
try {
|
try {
|
||||||
|
console.log('[Dashboard] Fetching room details via API');
|
||||||
const stats = await colyseusService.fetchRoomStats(roomId);
|
const stats = await colyseusService.fetchRoomStats(roomId);
|
||||||
roomDetails.value[roomId] = stats;
|
roomDetails.value[roomId] = stats;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -217,16 +215,6 @@ async function kickPlayer(roomId: string, playerId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timestamp: number): string {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
return date.toLocaleTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSeconds(seconds: number): string {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshData() {
|
function refreshData() {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -235,6 +223,116 @@ function refreshData() {
|
|||||||
function goToLobby() {
|
function goToLobby() {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initSSE() {
|
||||||
|
try {
|
||||||
|
console.log('[Dashboard] Initializing SSE connection...');
|
||||||
|
eventSource.value = new EventSource(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api'}/dashboard-stream`);
|
||||||
|
|
||||||
|
eventSource.value.onopen = () => {
|
||||||
|
console.log('[Dashboard] SSE connection opened');
|
||||||
|
isSSEConnected.value = true;
|
||||||
|
reconnectAttempts.value = 0;
|
||||||
|
// Clear any existing polling interval
|
||||||
|
if (refreshInterval.value) {
|
||||||
|
clearInterval(refreshInterval.value);
|
||||||
|
refreshInterval.value = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.value.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log('[Dashboard] Received SSE data:', data);
|
||||||
|
|
||||||
|
// Update rooms, room details, and global stats from SSE
|
||||||
|
rooms.value = data.rooms || [];
|
||||||
|
roomDetails.value = data.roomDetails || {};
|
||||||
|
globalStats.value = data.globalStats || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Dashboard] Error parsing SSE data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.value.onerror = (error) => {
|
||||||
|
console.error('[Dashboard] SSE connection error:', error);
|
||||||
|
isSSEConnected.value = false;
|
||||||
|
|
||||||
|
// Close the current connection
|
||||||
|
if (eventSource.value) {
|
||||||
|
eventSource.value.close();
|
||||||
|
eventSource.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt reconnection with exponential backoff
|
||||||
|
if (reconnectAttempts.value < maxReconnectAttempts) {
|
||||||
|
reconnectAttempts.value++;
|
||||||
|
const delay = Math.pow(2, reconnectAttempts.value) * 1000; // 2s, 4s, 8s, 16s, 32s
|
||||||
|
console.log(`[Dashboard] Attempting SSE reconnection in ${delay}ms (attempt ${reconnectAttempts.value})`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isSSEConnected.value) {
|
||||||
|
initSSE();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
console.log('[Dashboard] Max SSE reconnection attempts reached, falling back to polling');
|
||||||
|
fallbackToPolling();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Dashboard] Failed to initialize SSE, falling back to polling:', error);
|
||||||
|
fallbackToPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackToPolling() {
|
||||||
|
console.log('[Dashboard] Using polling fallback');
|
||||||
|
isSSEConnected.value = false;
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
fetchData();
|
||||||
|
|
||||||
|
// Set up polling interval
|
||||||
|
if (refreshInterval.value) {
|
||||||
|
clearInterval(refreshInterval.value);
|
||||||
|
}
|
||||||
|
refreshInterval.value = setInterval(fetchData, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
// Close SSE connection
|
||||||
|
if (eventSource.value) {
|
||||||
|
eventSource.value.close();
|
||||||
|
eventSource.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear polling interval
|
||||||
|
if (refreshInterval.value) {
|
||||||
|
clearInterval(refreshInterval.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSSEConnected.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRoomModal(roomId: string) {
|
||||||
|
selectedRoomId.value = roomId;
|
||||||
|
// Auto-fetch room details if not already loaded and SSE is not connected
|
||||||
|
if (!roomDetails.value[roomId] && !isSSEConnected.value) {
|
||||||
|
viewRoomDetails(roomId);
|
||||||
|
}
|
||||||
|
isModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRoomModal() {
|
||||||
|
isModalOpen.value = false;
|
||||||
|
selectedRoomId.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRoom = computed(() => {
|
||||||
|
return gameRooms.value.find(room => room.roomId === selectedRoomId.value);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -250,9 +348,58 @@ function goToLobby() {
|
|||||||
margin: 0 auto 40px;
|
margin: 0 auto 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-header h1 {
|
.dashboard-header h1 {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
margin-bottom: 30px;
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.connected {
|
||||||
|
background: #4caf50;
|
||||||
|
box-shadow: 0 0 8px rgba(76, 175, 80, 0.6);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.disconnected {
|
||||||
|
background: #ff9800;
|
||||||
|
box-shadow: 0 0 8px rgba(255, 152, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-summary {
|
.stats-summary {
|
||||||
@@ -292,10 +439,45 @@ function goToLobby() {
|
|||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.rooms-section h2,
|
.rooms-section h2,
|
||||||
.lobby-section h2 {
|
.lobby-section h2 {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
margin-bottom: 20px;
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-view-mode {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-view-mode:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-view-mode.active {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #333;
|
||||||
|
border-color: rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-rooms {
|
.no-rooms {
|
||||||
@@ -312,80 +494,6 @@ function goToLobby() {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-card {
|
|
||||||
background: white;
|
|
||||||
color: #333;
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
border-bottom: 2px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-id {
|
|
||||||
font-weight: bold;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-status {
|
|
||||||
padding: 5px 15px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-waiting {
|
|
||||||
background: #e8f5e9;
|
|
||||||
color: #4caf50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-playing {
|
|
||||||
background: #e3f2fd;
|
|
||||||
color: #2196f3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-paused {
|
|
||||||
background: #fff3e0;
|
|
||||||
color: #ff9800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-finished {
|
|
||||||
background: #f3e5f5;
|
|
||||||
color: #9c27b0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-details {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-label {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-value {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -396,93 +504,11 @@ function goToLobby() {
|
|||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-action {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-pause {
|
|
||||||
background: #ff9800;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-resume {
|
|
||||||
background: #4caf50;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-restart {
|
|
||||||
background: #2196f3;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-view {
|
|
||||||
background: #9c27b0;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-kick {
|
|
||||||
background: #f44336;
|
|
||||||
color: white;
|
|
||||||
padding: 4px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-stats {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 2px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-stats h4 {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.players-list {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-name {
|
|
||||||
flex: 1;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-clicks {
|
|
||||||
margin-right: 15px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-info {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.winner-name {
|
|
||||||
color: #4caf50;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lobby-grid {
|
.lobby-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
@@ -8,7 +9,7 @@
|
|||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"emitDeclarationOnly": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
host: '0.0.0.0',
|
||||||
|
port: 3004,
|
||||||
|
allowedHosts: ['z590.interno.com'],
|
||||||
|
cors: {
|
||||||
|
origin: ['http://localhost:3004', 'http://z590.interno.com:3004']
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: 'http://localhost:3000',
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { Request, Response, Router } from "express";
|
|||||||
import { matchMaker } from "colyseus";
|
import { matchMaker } from "colyseus";
|
||||||
import { GameRoom } from "./rooms/GameRoom";
|
import { GameRoom } from "./rooms/GameRoom";
|
||||||
|
|
||||||
|
// SSE connections storage
|
||||||
|
const sseClients = new Set<Response>();
|
||||||
|
|
||||||
const adminRouter = Router();
|
const adminRouter = Router();
|
||||||
|
|
||||||
adminRouter.get("/rooms", async (req: Request, res: Response) => {
|
adminRouter.get("/rooms", async (req: Request, res: Response) => {
|
||||||
@@ -126,4 +129,130 @@ adminRouter.get("/stats", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { adminRouter };
|
// SSE endpoint for real-time dashboard updates
|
||||||
|
adminRouter.get("/dashboard-stream", (req: Request, res: Response) => {
|
||||||
|
// Set SSE headers
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add client to our set
|
||||||
|
sseClients.add(res);
|
||||||
|
console.log(`[AdminAPI] SSE client connected. Total clients: ${sseClients.size}`);
|
||||||
|
|
||||||
|
// Send initial data
|
||||||
|
sendDashboardUpdate(res);
|
||||||
|
|
||||||
|
// Handle client disconnect
|
||||||
|
req.on('close', () => {
|
||||||
|
sseClients.delete(res);
|
||||||
|
console.log(`[AdminAPI] SSE client disconnected. Total clients: ${sseClients.size}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep connection alive with periodic heartbeat
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
if (res.destroyed) {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
sseClients.delete(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.write(':heartbeat\n\n');
|
||||||
|
}, 30000); // 30 seconds
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to send dashboard data to SSE clients
|
||||||
|
async function sendDashboardUpdate(client?: Response) {
|
||||||
|
try {
|
||||||
|
const rooms = await matchMaker.query({});
|
||||||
|
const roomStats = rooms.map(room => ({
|
||||||
|
roomId: room.roomId,
|
||||||
|
name: room.name,
|
||||||
|
clients: room.clients,
|
||||||
|
maxClients: room.maxClients,
|
||||||
|
metadata: room.metadata,
|
||||||
|
locked: room.locked,
|
||||||
|
private: room.private,
|
||||||
|
createdAt: room.createdAt
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get detailed stats for all game rooms
|
||||||
|
const roomDetails: { [key: string]: any } = {};
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (room.name === 'game') {
|
||||||
|
try {
|
||||||
|
const detailData = await matchMaker.remoteRoomCall(room.roomId, "getState");
|
||||||
|
roomDetails[room.roomId] = detailData;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[AdminAPI] Failed to get details for room ${room.roomId}:`, error);
|
||||||
|
// Set empty details if room call fails
|
||||||
|
roomDetails[room.roomId] = {
|
||||||
|
players: [],
|
||||||
|
gameStatus: room.metadata?.gameStatus || 'waiting',
|
||||||
|
variant: room.metadata?.currentVariant || 'G1',
|
||||||
|
round: room.metadata?.currentRound || 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await matchMaker.stats.fetchAll();
|
||||||
|
const globalCCU = await matchMaker.stats.getGlobalCCU();
|
||||||
|
|
||||||
|
const dashboardData = {
|
||||||
|
rooms: roomStats,
|
||||||
|
roomDetails: roomDetails,
|
||||||
|
globalStats: {
|
||||||
|
processes: stats,
|
||||||
|
globalCCU,
|
||||||
|
localCCU: matchMaker.stats.local.ccu,
|
||||||
|
localRoomCount: matchMaker.stats.local.roomCount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = `data: ${JSON.stringify(dashboardData)}\n\n`;
|
||||||
|
|
||||||
|
if (client) {
|
||||||
|
// Send to specific client (for initial connection)
|
||||||
|
if (!client.destroyed) {
|
||||||
|
client.write(message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Broadcast to all clients
|
||||||
|
const deadClients: Response[] = [];
|
||||||
|
|
||||||
|
sseClients.forEach(client => {
|
||||||
|
if (client.destroyed) {
|
||||||
|
deadClients.push(client);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
client.write(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AdminAPI] Error writing to SSE client:', error);
|
||||||
|
deadClients.push(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up dead connections
|
||||||
|
deadClients.forEach(client => sseClients.delete(client));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AdminAPI] Error sending dashboard update:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to broadcast dashboard updates (called from room events)
|
||||||
|
function broadcastDashboardUpdate() {
|
||||||
|
sendDashboardUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { adminRouter, broadcastDashboardUpdate };
|
||||||
@@ -2,27 +2,45 @@ import { Room, Client } from "colyseus";
|
|||||||
import { GameState } from "./schemas/GameState";
|
import { GameState } from "./schemas/GameState";
|
||||||
import { GameStatus } from "../../../shared/types";
|
import { GameStatus } from "../../../shared/types";
|
||||||
import { NameManager } from "../utils/nameManager";
|
import { NameManager } from "../utils/nameManager";
|
||||||
|
import { broadcastDashboardUpdate } from "../adminApi";
|
||||||
|
|
||||||
export class GameRoom extends Room<GameState> {
|
export class GameRoom extends Room<GameState> {
|
||||||
maxClients = 2;
|
maxClients = 2;
|
||||||
private gameInterval?: NodeJS.Timeout;
|
private gameInterval?: NodeJS.Timeout;
|
||||||
|
private recentSystemMessage: { text: string; kind: string; timestamp: number } | null = null;
|
||||||
|
|
||||||
private sysChat(text: string, kind: string) {
|
private sysChat(text: string, kind: string) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
// Store the most recent system message for dashboard (exclude round changes)
|
||||||
|
if (kind !== 'round_advance') {
|
||||||
|
this.recentSystemMessage = { text, kind, timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
this.broadcast("chat", {
|
this.broadcast("chat", {
|
||||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
id: `${timestamp}-${Math.random().toString(36).slice(2)}`,
|
||||||
text,
|
text,
|
||||||
from: "Sistema",
|
from: "Sistema",
|
||||||
fromId: "system",
|
fromId: "system",
|
||||||
ts: Date.now(),
|
ts: timestamp,
|
||||||
kind,
|
kind,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
|
// Notify dashboard immediately after system message
|
||||||
|
setTimeout(() => {
|
||||||
|
broadcastDashboardUpdate();
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
onCreate(options: any) {
|
onCreate(options: any) {
|
||||||
this.setState(new GameState());
|
this.setState(new GameState());
|
||||||
this.state.roomId = this.roomId;
|
this.state.roomId = this.roomId;
|
||||||
// Expose status via metadata for lobby listing
|
// Expose status via metadata for lobby listing
|
||||||
this.setMetadata({ gameStatus: 'waiting' });
|
this.setMetadata({
|
||||||
|
gameStatus: 'waiting',
|
||||||
|
currentRound: this.state.currentRound,
|
||||||
|
currentVariant: this.state.currentVariant
|
||||||
|
});
|
||||||
|
|
||||||
// Variant selection (both players can change)
|
// Variant selection (both players can change)
|
||||||
this.onMessage("setVariant", (client, variant: string) => {
|
this.onMessage("setVariant", (client, variant: string) => {
|
||||||
@@ -34,6 +52,12 @@ export class GameRoom extends Room<GameState> {
|
|||||||
if (this.state.gameStatus === GameStatus.FINISHED) {
|
if (this.state.gameStatus === GameStatus.FINISHED) {
|
||||||
this.state.gameStatus = GameStatus.PLAYING;
|
this.state.gameStatus = GameStatus.PLAYING;
|
||||||
}
|
}
|
||||||
|
// Update metadata with new variant and round
|
||||||
|
this.setMetadata({
|
||||||
|
gameStatus: this.state.gameStatus === GameStatus.WAITING ? 'waiting' : 'playing',
|
||||||
|
currentRound: this.state.currentRound,
|
||||||
|
currentVariant: this.state.currentVariant
|
||||||
|
});
|
||||||
// G2: Force offer by default
|
// G2: Force offer by default
|
||||||
if (variant === 'G2') {
|
if (variant === 'G2') {
|
||||||
this.state.forcedByP2 = true;
|
this.state.forcedByP2 = true;
|
||||||
@@ -145,6 +169,9 @@ export class GameRoom extends Room<GameState> {
|
|||||||
const rE = this.state.requestElote;
|
const rE = this.state.requestElote;
|
||||||
if (p2.pavoTokens >= rP) { p2.pavoTokens -= rP; p1.pavoTokens += rP; }
|
if (p2.pavoTokens >= rP) { p2.pavoTokens -= rP; p1.pavoTokens += rP; }
|
||||||
if (p2.eloteTokens >= rE) { p2.eloteTokens -= rE; p1.eloteTokens += rE; }
|
if (p2.eloteTokens >= rE) { p2.eloteTokens -= rE; p1.eloteTokens += rE; }
|
||||||
|
|
||||||
|
// Notify dashboard of token changes
|
||||||
|
broadcastDashboardUpdate();
|
||||||
}
|
}
|
||||||
// Clear offer now
|
// Clear offer now
|
||||||
this.clearOffer();
|
this.clearOffer();
|
||||||
@@ -179,7 +206,11 @@ export class GameRoom extends Room<GameState> {
|
|||||||
if (assign && this.state.currentVariant === "G3" && this.state.p2Action === "snatch") {
|
if (assign && this.state.currentVariant === "G3" && this.state.p2Action === "snatch") {
|
||||||
// increment P2 shame immediately
|
// increment P2 shame immediately
|
||||||
const p2 = this.state.p2Id ? this.state.players.get(this.state.p2Id) : undefined;
|
const p2 = this.state.p2Id ? this.state.players.get(this.state.p2Id) : undefined;
|
||||||
if (p2) p2.shameTokens += 1;
|
if (p2) {
|
||||||
|
p2.shameTokens += 1;
|
||||||
|
// Notify dashboard of token change
|
||||||
|
broadcastDashboardUpdate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// System chat feedback
|
// System chat feedback
|
||||||
if (assign) this.sysChat('😶 P1 asignó un token de vergüenza a P2', 'p1_shame');
|
if (assign) this.sysChat('😶 P1 asignó un token de vergüenza a P2', 'p1_shame');
|
||||||
@@ -232,6 +263,16 @@ export class GameRoom extends Room<GameState> {
|
|||||||
roomId: this.roomId
|
roomId: this.roomId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// System message for player join
|
||||||
|
if (this.state.players.size === 1) {
|
||||||
|
this.sysChat(`👋 ${playerName} se unió - esperando oponente`, 'player_join');
|
||||||
|
} else if (this.state.players.size === 2) {
|
||||||
|
this.sysChat(`🎯 Todos los jugadores conectados`, 'players_ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify dashboard of player join
|
||||||
|
broadcastDashboardUpdate();
|
||||||
|
|
||||||
if (this.state.players.size === 2 && this.state.gameStatus === GameStatus.WAITING) {
|
if (this.state.players.size === 2 && this.state.gameStatus === GameStatus.WAITING) {
|
||||||
this.startGame();
|
this.startGame();
|
||||||
}
|
}
|
||||||
@@ -246,6 +287,9 @@ export class GameRoom extends Room<GameState> {
|
|||||||
// Don't release the name here - it's managed by the LobbyRoom
|
// Don't release the name here - it's managed by the LobbyRoom
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify dashboard of player leave
|
||||||
|
broadcastDashboardUpdate();
|
||||||
|
|
||||||
if (this.state.gameStatus === GameStatus.PLAYING) {
|
if (this.state.gameStatus === GameStatus.PLAYING) {
|
||||||
if (this.getConnectedPlayersCount() < 2) {
|
if (this.getConnectedPlayersCount() < 2) {
|
||||||
this.pauseGame();
|
this.pauseGame();
|
||||||
@@ -265,7 +309,13 @@ export class GameRoom extends Room<GameState> {
|
|||||||
} catch {}
|
} catch {}
|
||||||
if (this.state.gameStatus === GameStatus.PAUSED && this.getConnectedPlayersCount() === 2) {
|
if (this.state.gameStatus === GameStatus.PAUSED && this.getConnectedPlayersCount() === 2) {
|
||||||
this.state.resumeGame();
|
this.state.resumeGame();
|
||||||
this.setMetadata({ gameStatus: 'playing' });
|
this.setMetadata({
|
||||||
|
gameStatus: 'playing',
|
||||||
|
currentRound: this.state.currentRound,
|
||||||
|
currentVariant: this.state.currentVariant
|
||||||
|
});
|
||||||
|
// Notify dashboard of game resume
|
||||||
|
broadcastDashboardUpdate();
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// reconnection window expired; nothing to do here
|
// reconnection window expired; nothing to do here
|
||||||
@@ -305,7 +355,11 @@ export class GameRoom extends Room<GameState> {
|
|||||||
private startGame() {
|
private startGame() {
|
||||||
console.log(`[GameRoom] Starting demo game in room ${this.roomId}`);
|
console.log(`[GameRoom] Starting demo game in room ${this.roomId}`);
|
||||||
this.state.startGame();
|
this.state.startGame();
|
||||||
this.setMetadata({ gameStatus: 'playing' });
|
this.setMetadata({
|
||||||
|
gameStatus: 'playing',
|
||||||
|
currentRound: this.state.currentRound,
|
||||||
|
currentVariant: this.state.currentVariant
|
||||||
|
});
|
||||||
// G2: Force offer by default when starting game
|
// G2: Force offer by default when starting game
|
||||||
if (this.state.currentVariant === 'G2') {
|
if (this.state.currentVariant === 'G2') {
|
||||||
this.state.forcedByP2 = true;
|
this.state.forcedByP2 = true;
|
||||||
@@ -313,19 +367,35 @@ export class GameRoom extends Room<GameState> {
|
|||||||
this.broadcast("gameStart");
|
this.broadcast("gameStart");
|
||||||
// System chat: start at round 1
|
// System chat: start at round 1
|
||||||
this.sysChat(`▶️ Ronda ${this.state.currentRound}/3`, 'round_advance');
|
this.sysChat(`▶️ Ronda ${this.state.currentRound}/3`, 'round_advance');
|
||||||
|
// Notify dashboard of game start (with some delay to ensure sysChat is processed)
|
||||||
|
setTimeout(() => {
|
||||||
|
broadcastDashboardUpdate();
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
private pauseGame() {
|
private pauseGame() {
|
||||||
console.log(`[GameRoom] Pausing game in room ${this.roomId}`);
|
console.log(`[GameRoom] Pausing game in room ${this.roomId}`);
|
||||||
this.state.pauseGame();
|
this.state.pauseGame();
|
||||||
this.broadcast("gamePaused");
|
this.broadcast("gamePaused");
|
||||||
this.setMetadata({ gameStatus: 'paused' });
|
this.setMetadata({
|
||||||
|
gameStatus: 'paused',
|
||||||
|
currentRound: this.state.currentRound,
|
||||||
|
currentVariant: this.state.currentVariant
|
||||||
|
});
|
||||||
|
// Notify dashboard of game pause
|
||||||
|
broadcastDashboardUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private endGame() {
|
private endGame() {
|
||||||
console.log(`[GameRoom] Demo game ended in room ${this.roomId}`);
|
console.log(`[GameRoom] Demo game ended in room ${this.roomId}`);
|
||||||
this.broadcast("gameEnd", {});
|
this.broadcast("gameEnd", {});
|
||||||
this.setMetadata({ gameStatus: 'finished' });
|
this.setMetadata({
|
||||||
|
gameStatus: 'finished',
|
||||||
|
currentRound: this.state.currentRound,
|
||||||
|
currentVariant: this.state.currentVariant
|
||||||
|
});
|
||||||
|
// Notify dashboard of game end
|
||||||
|
broadcastDashboardUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveP2Action() {
|
private resolveP2Action() {
|
||||||
@@ -351,6 +421,8 @@ export class GameRoom extends Room<GameState> {
|
|||||||
p2.eloteTokens -= this.state.requestElote; p1.eloteTokens += this.state.requestElote;
|
p2.eloteTokens -= this.state.requestElote; p1.eloteTokens += this.state.requestElote;
|
||||||
}
|
}
|
||||||
this.clearOffer();
|
this.clearOffer();
|
||||||
|
// Notify dashboard of token changes
|
||||||
|
broadcastDashboardUpdate();
|
||||||
}
|
}
|
||||||
else if (p2Action === 'reject') {
|
else if (p2Action === 'reject') {
|
||||||
// No changes
|
// No changes
|
||||||
@@ -363,6 +435,8 @@ export class GameRoom extends Room<GameState> {
|
|||||||
p1.eloteTokens -= this.state.offerElote; p2.eloteTokens += this.state.offerElote;
|
p1.eloteTokens -= this.state.offerElote; p2.eloteTokens += this.state.offerElote;
|
||||||
}
|
}
|
||||||
// Keep offer data around for potential G4 report; it will be cleared on report or next round
|
// Keep offer data around for potential G4 report; it will be cleared on report or next round
|
||||||
|
// Notify dashboard of token changes
|
||||||
|
broadcastDashboardUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +460,11 @@ export class GameRoom extends Room<GameState> {
|
|||||||
|
|
||||||
this.state.restartGame();
|
this.state.restartGame();
|
||||||
this.broadcast("gameRestart");
|
this.broadcast("gameRestart");
|
||||||
this.setMetadata({ gameStatus: 'waiting' });
|
this.setMetadata({
|
||||||
|
gameStatus: 'waiting',
|
||||||
|
currentRound: this.state.currentRound,
|
||||||
|
currentVariant: this.state.currentVariant
|
||||||
|
});
|
||||||
|
|
||||||
if (this.state.players.size === 2) {
|
if (this.state.players.size === 2) {
|
||||||
setTimeout(() => this.startGame(), 500);
|
setTimeout(() => this.startGame(), 500);
|
||||||
@@ -411,7 +489,7 @@ export class GameRoom extends Room<GameState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getState() {
|
getState() {
|
||||||
return {
|
const result = {
|
||||||
roomId: this.roomId,
|
roomId: this.roomId,
|
||||||
players: Array.from(this.state.players.values()).map(p => ({
|
players: Array.from(this.state.players.values()).map(p => ({
|
||||||
sessionId: p.sessionId,
|
sessionId: p.sessionId,
|
||||||
@@ -420,10 +498,12 @@ export class GameRoom extends Room<GameState> {
|
|||||||
pavoTokens: p.pavoTokens,
|
pavoTokens: p.pavoTokens,
|
||||||
eloteTokens: p.eloteTokens,
|
eloteTokens: p.eloteTokens,
|
||||||
shameTokens: p.shameTokens,
|
shameTokens: p.shameTokens,
|
||||||
|
color: p.color,
|
||||||
})),
|
})),
|
||||||
gameStatus: this.state.gameStatus,
|
gameStatus: this.state.gameStatus,
|
||||||
variant: this.state.currentVariant,
|
variant: this.state.currentVariant,
|
||||||
round: this.state.currentRound,
|
round: this.state.currentRound,
|
||||||
|
recentSystemMessage: this.recentSystemMessage,
|
||||||
decisions: {
|
decisions: {
|
||||||
p1Action: this.state.p1Action,
|
p1Action: this.state.p1Action,
|
||||||
p2Action: this.state.p2Action,
|
p2Action: this.state.p2Action,
|
||||||
@@ -440,14 +520,24 @@ export class GameRoom extends Room<GameState> {
|
|||||||
},
|
},
|
||||||
outcome: {}
|
outcome: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private advanceRound() {
|
private advanceRound() {
|
||||||
if (this.state.currentRound < 3) {
|
if (this.state.currentRound < 3) {
|
||||||
this.state.currentRound += 1;
|
this.state.currentRound += 1;
|
||||||
this.state.resetRound();
|
this.state.resetRound();
|
||||||
|
// Update metadata with new round
|
||||||
|
this.setMetadata({
|
||||||
|
gameStatus: 'playing',
|
||||||
|
currentRound: this.state.currentRound,
|
||||||
|
currentVariant: this.state.currentVariant
|
||||||
|
});
|
||||||
this.broadcast("roundStarted", { round: this.state.currentRound });
|
this.broadcast("roundStarted", { round: this.state.currentRound });
|
||||||
this.sysChat(`▶️ Ronda ${this.state.currentRound}/3`, 'round_advance');
|
this.sysChat(`▶️ Ronda ${this.state.currentRound}/3`, 'round_advance');
|
||||||
|
// Notify dashboard of round advance
|
||||||
|
broadcastDashboardUpdate();
|
||||||
} else {
|
} else {
|
||||||
this.state.finishGame();
|
this.state.finishGame();
|
||||||
this.endGame();
|
this.endGame();
|
||||||
|
|||||||
Reference in New Issue
Block a user