reset UUID y shame persistente
This commit is contained in:
112
client/src/components/DashboardActions.vue
Normal file
112
client/src/components/DashboardActions.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="actions-container">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>📥 Export / Tools</h2>
|
||||||
|
</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="btn btn-export" @click="downloadCsv">Descargar resultados (CSV)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
interface PlayerRow {
|
||||||
|
sessionId: string;
|
||||||
|
uuid?: string;
|
||||||
|
name: string;
|
||||||
|
role: 'P1' | 'P2' | '';
|
||||||
|
pavoTokens?: number;
|
||||||
|
eloteTokens?: number;
|
||||||
|
shameTokens?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomDetail {
|
||||||
|
roomId: string;
|
||||||
|
players?: PlayerRow[];
|
||||||
|
gameStatus?: string;
|
||||||
|
variant?: string;
|
||||||
|
round?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ rooms: any[]; roomDetails: { [key: string]: RoomDetail } }>();
|
||||||
|
|
||||||
|
const gameRooms = computed(() => props.rooms.filter(r => r.name === 'game'));
|
||||||
|
|
||||||
|
function csvEscape(v: any): string {
|
||||||
|
const s = String(v ?? '').replace(/\r?\n/g, ' ');
|
||||||
|
if (/[",]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsv(): string {
|
||||||
|
const headers = [
|
||||||
|
'roomId', 'variant', 'round', 'status',
|
||||||
|
'p1_name', 'p1_pavo', 'p1_elote',
|
||||||
|
'p2_name', 'p2_pavo', 'p2_elote'
|
||||||
|
];
|
||||||
|
const lines: string[] = [headers.join(',')];
|
||||||
|
|
||||||
|
gameRooms.value.forEach(room => {
|
||||||
|
const det = props.roomDetails[room.roomId] || {};
|
||||||
|
const status = (det as any).gameStatus || room?.metadata?.gameStatus || 'waiting';
|
||||||
|
const variant = (det as any).variant || room?.metadata?.currentVariant || 'G1';
|
||||||
|
const round = (det as any).round || room?.metadata?.currentRound || 1;
|
||||||
|
const players = (det.players || []) as PlayerRow[];
|
||||||
|
const p1 = players.find(p => p.role === 'P1') || players[0] || ({} as any);
|
||||||
|
const p2 = players.find(p => p.role === 'P2') || players[1] || ({} as any);
|
||||||
|
|
||||||
|
const row = [
|
||||||
|
room.roomId,
|
||||||
|
variant,
|
||||||
|
round,
|
||||||
|
status,
|
||||||
|
p1?.name || '',
|
||||||
|
p1?.pavoTokens ?? 0,
|
||||||
|
p1?.eloteTokens ?? 0,
|
||||||
|
p2?.name || '',
|
||||||
|
p2?.pavoTokens ?? 0,
|
||||||
|
p2?.eloteTokens ?? 0
|
||||||
|
].map(csvEscape).join(',');
|
||||||
|
lines.push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCsv() {
|
||||||
|
const csv = buildCsv();
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const fname = `snatch-results-${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.csv`;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fname;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.actions-container {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;
|
||||||
|
}
|
||||||
|
.section-header h2 { margin:0; font-size: 1.3rem; }
|
||||||
|
.buttons { display:flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.btn { padding: 10px 14px; border:none; border-radius: 10px; font-weight:700; cursor:pointer; }
|
||||||
|
.btn-export { background: linear-gradient(135deg, #10b981 0%, #059669 100%); color:white; }
|
||||||
|
.btn-export:hover { filter: brightness(1.05); }
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -123,6 +123,14 @@
|
|||||||
>
|
>
|
||||||
🏠 Send All to Lobby
|
🏠 Send All to Lobby
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
@click="resetAllUuidProfiles"
|
||||||
|
class="btn btn-reset-profiles"
|
||||||
|
:disabled="isLoadingGlobal"
|
||||||
|
title="Borrar nombre, color y vergüenza de todos los UUIDs"
|
||||||
|
>
|
||||||
|
🧹 Reset All UUID Profiles
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -443,8 +451,8 @@ async function pauseAllGames() {
|
|||||||
console.error('Failed to pause all games:', error);
|
console.error('Failed to pause all games:', error);
|
||||||
alert('Failed to pause all games. Check console for details.');
|
alert('Failed to pause all games. Check console for details.');
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingGlobal.value = false;
|
isLoadingGlobal.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumeAllGames() {
|
async function resumeAllGames() {
|
||||||
@@ -561,6 +569,40 @@ async function sendAllToLobby() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetAllUuidProfiles() {
|
||||||
|
if (!confirm('¿Seguro que deseas resetear nombre, color y vergüenza de TODOS los UUIDs? Esta acción no se puede deshacer.')) return;
|
||||||
|
isLoadingGlobal.value = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBase()}/admin/reset-uuid-profiles`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to reset UUID profiles');
|
||||||
|
const result = await response.json();
|
||||||
|
console.log(result?.message || 'UUID profiles reset');
|
||||||
|
alert(result?.message || 'UUID profiles reset');
|
||||||
|
await fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset UUID profiles:', error);
|
||||||
|
alert('Failed to reset UUID profiles. Check console for details.');
|
||||||
|
} finally {
|
||||||
|
isLoadingGlobal.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build API base from env or current origin
|
||||||
|
function apiBase(): string {
|
||||||
|
try {
|
||||||
|
const raw = (import.meta.env as any)?.VITE_API_URL as string | undefined;
|
||||||
|
const env = (raw || '').trim();
|
||||||
|
if (env) return env.replace(/\/$/, '');
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000';
|
||||||
|
return `${origin.replace(/\/$/, '')}/api`;
|
||||||
|
} catch {
|
||||||
|
return 'http://localhost:3000/api';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initSSE() {
|
function initSSE() {
|
||||||
try {
|
try {
|
||||||
console.log('[Dashboard] Initializing SSE connection...');
|
console.log('[Dashboard] Initializing SSE connection...');
|
||||||
@@ -853,6 +895,11 @@ const selectedRoom = computed(() => {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-reset-profiles {
|
||||||
|
background: linear-gradient(135deg, #6b7280 0%, #374151 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="game">
|
<div class="game">
|
||||||
|
<!-- End of game modal overlay -->
|
||||||
|
<div v-if="endModal.visible" class="end-modal-overlay" @click.self="dismissEndModal">
|
||||||
|
<div class="end-modal">
|
||||||
|
<button class="close-btn" @click="dismissEndModal" aria-label="Cerrar">×</button>
|
||||||
|
<div class="title">🏁 Juego finalizado</div>
|
||||||
|
<div class="scores">
|
||||||
|
<div v-for="s in finalScores" :key="s.sessionId" class="score-row">
|
||||||
|
<span class="name">{{ s.name }}</span>
|
||||||
|
<span class="tokens">🦃 {{ s.pavo }} · 🌽 {{ s.elote }}</span>
|
||||||
|
<span class="points">Puntos: {{ s.points }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hint">Se cerrará en {{ remainingSeconds }}s</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="game-container">
|
<div class="game-container">
|
||||||
<div class="game-header">
|
<div class="game-header">
|
||||||
<h1>🧪 Demo Room</h1>
|
<h1>🧪 Demo Room</h1>
|
||||||
@@ -99,6 +114,58 @@ const outcomeP2 = ref(0);
|
|||||||
|
|
||||||
const variants = ['G1','G2','G3','G4','G5'];
|
const variants = ['G1','G2','G3','G4','G5'];
|
||||||
|
|
||||||
|
// End-of-game modal state and helpers
|
||||||
|
const endModal = ref<{ visible: boolean }>({ visible: false });
|
||||||
|
const remainingSeconds = ref(10);
|
||||||
|
let endTimerTimeout: any = null;
|
||||||
|
let endTimerInterval: any = null;
|
||||||
|
|
||||||
|
function showEndModal() {
|
||||||
|
// Prevent multiple timers
|
||||||
|
if (endModal.value.visible) return;
|
||||||
|
endModal.value.visible = true;
|
||||||
|
remainingSeconds.value = 10;
|
||||||
|
if (endTimerInterval) clearInterval(endTimerInterval);
|
||||||
|
if (endTimerTimeout) clearTimeout(endTimerTimeout);
|
||||||
|
endTimerInterval = setInterval(() => {
|
||||||
|
remainingSeconds.value = Math.max(0, remainingSeconds.value - 1);
|
||||||
|
}, 1000);
|
||||||
|
endTimerTimeout = setTimeout(() => {
|
||||||
|
dismissEndModal();
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissEndModal() {
|
||||||
|
endModal.value.visible = false;
|
||||||
|
if (endTimerInterval) { clearInterval(endTimerInterval); endTimerInterval = null; }
|
||||||
|
if (endTimerTimeout) { clearTimeout(endTimerTimeout); endTimerTimeout = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalScores = computed(() => {
|
||||||
|
return players.value.map(p => {
|
||||||
|
const points = (p.role === 'P2')
|
||||||
|
? (p.eloteTokens || 0) * 1 + (p.pavoTokens || 0) * 2
|
||||||
|
: (p.pavoTokens || 0) * 1 + (p.eloteTokens || 0) * 2;
|
||||||
|
return {
|
||||||
|
sessionId: p.sessionId,
|
||||||
|
name: p.name,
|
||||||
|
pavo: p.pavoTokens || 0,
|
||||||
|
elote: p.eloteTokens || 0,
|
||||||
|
points
|
||||||
|
};
|
||||||
|
}).sort((a, b) => b.points - a.points);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Round transition banner state and helper
|
||||||
|
const roundBanner = ref<{ visible: boolean; text: string; kind: 'start'|'end' }>({ visible: false, text: '', kind: 'start' });
|
||||||
|
let roundBannerTimeout: any = null;
|
||||||
|
|
||||||
|
function showRoundBanner(text: string, kind: 'start'|'end', ms = 1400) {
|
||||||
|
if (roundBannerTimeout) { clearTimeout(roundBannerTimeout); roundBannerTimeout = null; }
|
||||||
|
roundBanner.value = { visible: true, text, kind };
|
||||||
|
roundBannerTimeout = setTimeout(() => { roundBanner.value.visible = false; }, ms);
|
||||||
|
}
|
||||||
|
|
||||||
const sessionId = computed(() => colyseusService.sessionId.value);
|
const sessionId = computed(() => colyseusService.sessionId.value);
|
||||||
const myRole = computed(() => {
|
const myRole = computed(() => {
|
||||||
const me = players.value.find(p => p.sessionId === sessionId.value);
|
const me = players.value.find(p => p.sessionId === sessionId.value);
|
||||||
@@ -141,7 +208,12 @@ onMounted(() => {
|
|||||||
gameStatus.value = state.gameStatus || 'waiting';
|
gameStatus.value = state.gameStatus || 'waiting';
|
||||||
});
|
});
|
||||||
|
|
||||||
$(room.state).listen("gameStatus", (value: string) => { gameStatus.value = value; });
|
$(room.state).listen("gameStatus", (value: string) => {
|
||||||
|
gameStatus.value = value;
|
||||||
|
if ((value || '').toLowerCase() === 'finished') {
|
||||||
|
showEndModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
$(room.state).listen("roomId", (value: string) => { roomId.value = value; });
|
$(room.state).listen("roomId", (value: string) => { roomId.value = value; });
|
||||||
$(room.state).listen("currentVariant", (value: string) => { currentVariant.value = value as any; });
|
$(room.state).listen("currentVariant", (value: string) => { currentVariant.value = value as any; });
|
||||||
$(room.state).listen("currentRound", (value: number) => { currentRound.value = value; });
|
$(room.state).listen("currentRound", (value: number) => { currentRound.value = value; });
|
||||||
@@ -186,9 +258,7 @@ onMounted(() => {
|
|||||||
colyseusService.playerName.value = info.name;
|
colyseusService.playerName.value = info.name;
|
||||||
});
|
});
|
||||||
|
|
||||||
room.onMessage("gameEnd", () => {
|
room.onMessage("gameEnd", () => { showEndModal(); });
|
||||||
// no-op for local storage
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register additional message handlers to avoid warnings
|
// Register additional message handlers to avoid warnings
|
||||||
room.onMessage("gamePaused", () => {
|
room.onMessage("gamePaused", () => {
|
||||||
@@ -203,6 +273,8 @@ onMounted(() => {
|
|||||||
currentVariant.value = data.variant as any;
|
currentVariant.value = data.variant as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// No round transition banners
|
||||||
|
|
||||||
// Handle room closure/disconnection
|
// Handle room closure/disconnection
|
||||||
room.onLeave((code: number) => {
|
room.onLeave((code: number) => {
|
||||||
console.log('[DemoGame] Room disconnected with code:', code);
|
console.log('[DemoGame] Room disconnected with code:', code);
|
||||||
@@ -266,6 +338,17 @@ function leaveGame() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.game { min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display:flex; align-items:center; justify-content:center; padding:20px; }
|
.game { min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display:flex; align-items:center; justify-content:center; padding:20px; }
|
||||||
|
.end-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display:flex; align-items:center; justify-content:center; z-index: 1200; }
|
||||||
|
.end-modal { position: relative; background: white; color:#111; border-radius: 16px; padding: 24px 24px 18px; width: min(520px, 92vw); box-shadow: 0 30px 80px rgba(0,0,0,0.5); border:1px solid #e5e7eb; }
|
||||||
|
.end-modal .close-btn { position:absolute; top:8px; right:8px; width:32px; height:32px; border-radius: 8px; border:1px solid #e5e7eb; background:#f8fafc; color:#111; font-weight:800; cursor:pointer; }
|
||||||
|
.end-modal .close-btn:hover { background:#eef2ff; }
|
||||||
|
.end-modal .title { font-size: 20px; font-weight: 900; margin-bottom: 8px; }
|
||||||
|
.end-modal .scores { display:flex; flex-direction:column; gap:8px; margin: 8px 0 12px; }
|
||||||
|
.end-modal .score-row { display:flex; align-items:center; justify-content:space-between; gap:8px; background:#f8fafc; border:1px solid #e5e7eb; border-radius: 10px; padding:8px 10px; }
|
||||||
|
.end-modal .score-row .name { font-weight:800; color:#1f2937; }
|
||||||
|
.end-modal .score-row .tokens { font-weight:700; color:#374151; }
|
||||||
|
.end-modal .score-row .points { font-weight:900; color:#111827; }
|
||||||
|
.end-modal .hint { font-size: 12px; color:#6b7280; text-align:right; }
|
||||||
.game-container { background: white; border-radius: 20px; padding: 24px; max-width: 1000px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
|
.game-container { background: white; border-radius: 20px; padding: 24px; max-width: 1000px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
|
||||||
.game-header { display:flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
.game-header { display:flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
.game-header h1 { margin: 0; font-size: 20px; }
|
.game-header h1 { margin: 0; font-size: 20px; }
|
||||||
|
|||||||
@@ -336,6 +336,32 @@ adminRouter.post("/admin/send-all-to-lobby", async (req: Request, res: Response)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset all stored UUID profiles (name, color, shame tokens)
|
||||||
|
adminRouter.post("/admin/reset-uuid-profiles", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const allowlist = listAllowedUuids();
|
||||||
|
const known = NameManager.getInstance().getAllKnownUuids();
|
||||||
|
const all = Array.from(new Set([...(allowlist || []), ...(known || [])]));
|
||||||
|
|
||||||
|
let resetCount = 0;
|
||||||
|
all.forEach(uuid => {
|
||||||
|
if (uuid) {
|
||||||
|
NameManager.getInstance().clearPlayerProfile(uuid);
|
||||||
|
resetCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optionally, we could also update active rooms, but we keep it as future joins behavior.
|
||||||
|
// Broadcast dashboard update so clients refresh any derived data
|
||||||
|
setTimeout(() => { try { broadcastDashboardUpdate(); } catch {} }, 100);
|
||||||
|
|
||||||
|
res.json({ success: true, message: `Reset profiles for ${resetCount} UUIDs` });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AdminAPI] Error resetting UUID profiles:", error);
|
||||||
|
res.status(500).json({ error: "Failed to reset UUID profiles" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
adminRouter.post("/admin/shuffle-players", async (req: Request, res: Response) => {
|
adminRouter.post("/admin/shuffle-players", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
console.log("[AdminAPI] Starting player shuffle...");
|
console.log("[AdminAPI] Starting player shuffle...");
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ export class NameManager {
|
|||||||
return Array.from(this.uuidToName.values());
|
return Array.from(this.uuidToName.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List all UUIDs that have any stored profile data
|
||||||
|
getAllKnownUuids(): string[] {
|
||||||
|
const set = new Set<string>();
|
||||||
|
this.uuidToName.forEach((_, k) => set.add(k));
|
||||||
|
this.uuidToColor.forEach((_, k) => set.add(k));
|
||||||
|
this.uuidToShame.forEach((_, k) => set.add(k));
|
||||||
|
return Array.from(set.values());
|
||||||
|
}
|
||||||
|
|
||||||
// Sticky shame tokens per UUID
|
// Sticky shame tokens per UUID
|
||||||
setShameTokens(uuid: string, count: number): void {
|
setShameTokens(uuid: string, count: number): void {
|
||||||
const n = Math.max(0, Math.floor(count || 0));
|
const n = Math.max(0, Math.floor(count || 0));
|
||||||
@@ -81,6 +90,14 @@ export class NameManager {
|
|||||||
return this.uuidToShame.get(uuid) || 0;
|
return this.uuidToShame.get(uuid) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear stored profile data for a UUID (name, color, shame)
|
||||||
|
clearPlayerProfile(uuid: string): void {
|
||||||
|
if (!uuid) return;
|
||||||
|
this.uuidToName.delete(uuid);
|
||||||
|
this.uuidToColor.delete(uuid);
|
||||||
|
this.uuidToShame.set(uuid, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Current game room assignment (for reconnection by UUID)
|
// Current game room assignment (for reconnection by UUID)
|
||||||
setCurrentRoom(uuid: string, roomId: string): void {
|
setCurrentRoom(uuid: string, roomId: string): void {
|
||||||
if (!uuid || !roomId) return;
|
if (!uuid || !roomId) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user