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:
2025-08-11 23:07:09 -06:00
parent deb63d4e38
commit 32f69805f0
9 changed files with 1689 additions and 265 deletions

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

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

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

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

View File

@@ -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));

View File

@@ -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,

View File

@@ -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',

View File

@@ -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 };

View File

@@ -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();