reset UUID y shame persistente

This commit is contained in:
2025-08-16 00:56:16 -06:00
parent b18397deb4
commit 730c7bda9e
5 changed files with 291 additions and 6 deletions

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

View File

@@ -123,6 +123,14 @@
>
🏠 Send All to Lobby
</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>
@@ -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() {
try {
console.log('[Dashboard] Initializing SSE connection...');
@@ -853,6 +895,11 @@ const selectedRoom = computed(() => {
color: white;
}
.btn-reset-profiles {
background: linear-gradient(135deg, #6b7280 0%, #374151 100%);
color: white;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;

View File

@@ -1,5 +1,20 @@
<template>
<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-header">
<h1>🧪 Demo Room</h1>
@@ -99,6 +114,58 @@ const outcomeP2 = ref(0);
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 myRole = computed(() => {
const me = players.value.find(p => p.sessionId === sessionId.value);
@@ -141,7 +208,12 @@ onMounted(() => {
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("currentVariant", (value: string) => { currentVariant.value = value as any; });
$(room.state).listen("currentRound", (value: number) => { currentRound.value = value; });
@@ -186,9 +258,7 @@ onMounted(() => {
colyseusService.playerName.value = info.name;
});
room.onMessage("gameEnd", () => {
// no-op for local storage
});
room.onMessage("gameEnd", () => { showEndModal(); });
// Register additional message handlers to avoid warnings
room.onMessage("gamePaused", () => {
@@ -203,6 +273,8 @@ onMounted(() => {
currentVariant.value = data.variant as any;
});
// No round transition banners
// Handle room closure/disconnection
room.onLeave((code: number) => {
console.log('[DemoGame] Room disconnected with code:', code);
@@ -266,6 +338,17 @@ function leaveGame() {
<style scoped>
.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-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; }

View File

@@ -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) => {
try {
console.log("[AdminAPI] Starting player shuffle...");

View File

@@ -71,6 +71,15 @@ export class NameManager {
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
setShameTokens(uuid: string, count: number): void {
const n = Math.max(0, Math.floor(count || 0));
@@ -81,6 +90,14 @@ export class NameManager {
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)
setCurrentRoom(uuid: string, roomId: string): void {
if (!uuid || !roomId) return;