Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -916,7 +916,11 @@ io.on('connection', (socket) => {

// Salir de lobby
socket.on('lobby:leave', (data) => {
const truco = lobbyManager.trucoGames.get(data?.lobbyId);
const seat = truco ? truco.seats.findIndex((s) => s.id === currentUserId) : -1;
const result = currentUserId ? lobbyManager.leaveLobby(data.lobbyId, currentUserId) : { success: false, error: 'Sesión inválida' };
// Truco: irse en plena partida la pierde (si no, los demás esperan el reloj mano tras mano)
if (result.success && seat >= 0 && !truco.match.over) trucoStep(data.lobbyId, truco, Truco.abandon(truco.match, seat).events);
socket.emit('lobby:left', result);
socket.emit('lobbyLeft', result);
if (result.success) {
Expand Down
15 changes: 15 additions & 0 deletions server/test/truco.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,21 @@ async function mesa(n, target = 15) {
console.log(`✓ partida completa ${fin.scores.join('-')}`);

[a, b].forEach((s) => s.close());

// Levantarse en plena partida la pierde
{
const { socks: [c, d], lobby: l2 } = await mesa(2);
let last;
c.on('truco:state', (st) => (last = st));
c.emit('game:start', { lobbyId: l2.id });
await sleep(300);
d.emit('lobby:leave', { lobbyId: l2.id });
await sleep(300);
assert.ok(last.over && last.winner === last.seat % 2, 'gana el que se quedó');
assert.ok(last.events.some((e) => e.type === 'abandono'));
[c, d].forEach((s) => s.close());
console.log('✓ el que se va pierde');
}
console.log('TRUCO OK');
} finally {
server?.kill();
Expand Down
1 change: 1 addition & 0 deletions src/components/TrucoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ const SIZES = {
xs: "w-8",
sm: "w-12",
md: "w-16",
played: "w-11 sm:w-16",
hand: "w-[clamp(72px,11vw,120px)]",
pile: "w-[clamp(84px,11vw,132px)]",
} as const;
Expand Down
362 changes: 362 additions & 0 deletions src/components/TrucoTable.tsx

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions src/hooks/useSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,9 @@ export const useSocket = (): UseSocketReturn => {
maxPlayers: lobby.maxPlayers,
...(lobby.mode && { mode: lobby.mode }),
...(lobby.onchain && { onchain: lobby.onchain }),
...(lobby.entryCost && { entryCost: lobby.entryCost })
...(lobby.entryCost && { entryCost: lobby.entryCost }),
...(lobby.game && { game: lobby.game }),
...(lobby.target && { target: lobby.target })
})),
...data.inGame.map((lobby: any) => ({
id: lobby.id,
Expand All @@ -285,7 +287,9 @@ export const useSocket = (): UseSocketReturn => {
maxPlayers: lobby.players,
...(lobby.mode && { mode: lobby.mode }),
...(lobby.onchain && { onchain: lobby.onchain }),
...(lobby.entryCost && { entryCost: lobby.entryCost })
...(lobby.entryCost && { entryCost: lobby.entryCost }),
...(lobby.game && { game: lobby.game }),
...(lobby.target && { target: lobby.target })
}))
];

Expand Down
53 changes: 53 additions & 0 deletions src/hooks/useTruco.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useCallback, useEffect, useState } from 'react';
import { socketService } from '../services/socketService';
import { useSocketContext } from '../contexts/SocketContext';
import type { TrucoAction, TrucoEvent, TrucoState } from '../types/truco';

export type Bubble = { ev: TrucoEvent; id: number };
export type Puntos = Extract<TrucoEvent, { type: 'puntos' }>;

let seq = 0;

// Estado de la mesa de truco. Se monta al tope de la página: el server manda truco:state apenas empieza
export function useTruco(lobbyId: string) {
const { isConnected } = useSocketContext();
const [state, setState] = useState<TrucoState | null>(null);
const [bubbles, setBubbles] = useState<Record<number, Bubble>>({});
const [handPoints, setHandPoints] = useState<Puntos[]>([]);
const [notice, setNotice] = useState<{ kind: 'timeout' | 'left'; seat: number } | null>(null);
const [version, setVersion] = useState(0); // reinicia el reloj en cada estado nuevo

useEffect(() => {
const onState = (st: TrucoState) => {
if (st.lobbyId !== lobbyId) return;
setState(st);
setVersion((v) => v + 1);
if (st.events.some((e) => e.type === 'nuevaMano')) {
setBubbles({});
setHandPoints([]);
setNotice(null);
}
const said = st.events.filter((e) => ['canto', 'quiero', 'noQuiero', 'mazo', 'envido'].includes(e.type));
if (said.length) {
setBubbles((b) => {
const next = { ...b };
for (const ev of said) next[(ev as { seat: number }).seat] = { ev, id: ++seq };
return next;
});
}
const pts = st.events.filter((e): e is Puntos => e.type === 'puntos');
if (pts.length) setHandPoints((p) => [...p, ...pts]);
const auto = st.events.find((e) => e.auto === 'timeout' && 'seat' in e) as { seat: number } | undefined;
if (auto) setNotice({ kind: 'timeout', seat: auto.seat });
const left = st.events.find((e) => e.type === 'abandono') as { seat: number } | undefined;
if (left) setNotice({ kind: 'left', seat: left.seat });
};
socketService.on('truco:state', onState);
return () => socketService.off('truco:state', onState);
// isConnected: en una recarga el socket se crea después del primer render
}, [lobbyId, isConnected]);

const act = useCallback((action: TrucoAction) => socketService.trucoAct(lobbyId, action).catch(() => {}), [lobbyId]);

return { state, version, bubbles, handPoints, notice, act };
}
86 changes: 86 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,47 @@ const resources = {
msg_lobby_closed: 'El creador cerró la mesa',
msg_recovering: 'Recuperando tu entrada on-chain…',
msg_timeout: '⏱ Se acabó tu tiempo',
truco_name: 'Truco',
truco_mode: '{{mode}}',
truco_to: 'a {{n}}',
truco_points: 'Puntos',
truco_team: 'Equipo {{n}}',
truco_need_full_one: 'El truco se juega con la mesa llena: falta {{count}}',
truco_need_full_other: 'El truco se juega con la mesa llena: faltan {{count}}',
truco_call_E: 'Envido',
truco_call_R: 'Real envido',
truco_call_F: 'Falta envido',
truco_call_truco: 'Truco',
truco_call_retruco: 'Retruco',
truco_call_valeCuatro: 'Vale cuatro',
truco_quiero: 'Quiero',
truco_no_quiero: 'No quiero',
truco_quiero_subir: 'Quiero {{call}}',
truco_tanto: '{{n}} de envido',
truco_mazo_said: 'Me voy al mazo',
truco_mazo: 'Irse al mazo',
truco_mazo_confirm: '¿Seguro? Tocá de nuevo',
truco_hand_over: 'Terminó la mano',
truco_sitting_out: 'Pica-pica: mirás este duelo',
truco_waiting_answer: 'Cantaron {{call}}: esperando respuesta',
truco_they_called: 'Te cantaron {{call}}',
truco_reason_mano: 'la mano',
truco_reason_envido: 'envido',
truco_reason_noQuiero: 'no quiso',
truco_reason_mazo: 'se fue al mazo',
truco_us: 'Nosotros',
truco_them: 'Ellos',
truco_malas: 'malas',
truco_buenas: 'buenas',
truco_timeout: '⏱ A {{player}} se le acabó el tiempo',
truco_left: '🚪 {{player}} se levantó de la mesa',
truco_pica: 'Pica-pica',
truco_mano: 'Mano',
truco_bazas: 'Bazas',
truco_envido_of: 'Envido de {{player}}: {{n}}',
truco_we_won: '¡Ganamos!',
truco_we_lost: 'Perdimos',
truco_you_lost: 'Perdiste',
},
},
en: {
Expand Down Expand Up @@ -329,6 +370,47 @@ const resources = {
msg_lobby_closed: 'The host closed the table',
msg_recovering: 'Recovering your entry on-chain…',
msg_timeout: "⏱ Time's up",
truco_name: 'Truco',
truco_mode: '{{mode}}',
truco_to: 'to {{n}}',
truco_points: 'Points',
truco_team: 'Team {{n}}',
truco_need_full_one: 'Truco needs a full table: {{count}} seat left',
truco_need_full_other: 'Truco needs a full table: {{count}} seats left',
truco_call_E: 'Envido',
truco_call_R: 'Real envido',
truco_call_F: 'Falta envido',
truco_call_truco: 'Truco',
truco_call_retruco: 'Retruco',
truco_call_valeCuatro: 'Vale cuatro',
truco_quiero: 'Accept',
truco_no_quiero: 'Decline',
truco_quiero_subir: 'Accept + {{call}}',
truco_tanto: '{{n}} envido',
truco_mazo_said: 'I fold',
truco_mazo: 'Fold',
truco_mazo_confirm: 'Sure? Tap again',
truco_hand_over: 'Hand over',
truco_sitting_out: 'Pica-pica: watching this duel',
truco_waiting_answer: '{{call}} called: waiting for an answer',
truco_they_called: 'They called {{call}}',
truco_reason_mano: 'the hand',
truco_reason_envido: 'envido',
truco_reason_noQuiero: 'declined',
truco_reason_mazo: 'folded',
truco_us: 'Us',
truco_them: 'Them',
truco_malas: 'bad',
truco_buenas: 'good',
truco_timeout: "⏱ {{player}} ran out of time",
truco_left: '🚪 {{player}} left the table',
truco_pica: 'Pica-pica',
truco_mano: 'Lead',
truco_bazas: 'Tricks',
truco_envido_of: "{{player}}'s envido: {{n}}",
truco_we_won: 'We won!',
truco_we_lost: 'We lost',
truco_you_lost: 'You lost',
},
},
}
Expand Down Expand Up @@ -399,6 +481,10 @@ const ERRORS_EN: Record<string, string> = {
'Tipo de mesa inválido': 'Invalid table type',
'Ponele un nombre a la mesa': 'Give the table a name',
'La partida ya empezó': 'The game already started',
'No estás jugando en esta mesa': "You're not playing at this table",
'El truco todavía no tiene mesas pagas': 'Truco has no paid tables yet',
'El truco se juega de a 2, 4 o 6': 'Truco is played with 2, 4 or 6',
'El truco se juega a 15 o a 30': 'Truco is played to 15 or 30',
// prefijos
'No se pudo cancelar on-chain: ': "Couldn't cancel on-chain: ",
'Red no soportada: ': 'Unsupported network: ',
Expand Down
25 changes: 25 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,28 @@ body {
transition-duration: 0.01ms !important;
}
}

/* Globo de canto del truco: aparece, se lee y se va */
@keyframes bubble {
0% { transform: translate(-50%, 6px) scale(0.6); opacity: 0; }
8% { transform: translate(-50%, 0) scale(1.08); opacity: 1; }
12%, 85% { transform: translate(-50%, 0) scale(1); opacity: 1; }
100% { transform: translate(-50%, -6px) scale(1); opacity: 0; }
}
.animate-bubble {
animation: bubble 4s ease-out forwards;
}
@media (prefers-reduced-motion: reduce) {
.animate-bubble { animation-name: none !important; transform: translate(-50%, 0); }
}

/* Fósforo que se suma al tanteador */
@keyframes fosforo {
from { opacity: 0; transform: scale(0.4); }
to { opacity: 1; transform: scale(1); }
}
.animate-fosforo {
transform-box: fill-box;
transform-origin: center;
animation: fosforo 0.45s cubic-bezier(0.2, 0.9, 0.3, 1.3) backwards;
}
Loading
Loading