166 lines
9.3 KiB
TypeScript
166 lines
9.3 KiB
TypeScript
|
|
import React, { useMemo } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { Trophy, Table, CheckCircle2, Medal, SearchX } from 'lucide-react';
|
|
import { useCTF } from './CTFContext';
|
|
import { calculateTeamTotalScore, calculateChallengeValue, getFirstBloodBonusFactor } from './services/scoring';
|
|
import { Button } from './UIComponents';
|
|
|
|
export const Scoreboard: React.FC = () => {
|
|
const { state } = useCTF();
|
|
const rankings = useMemo(() => state.teams.filter(t => !t.isAdmin && !t.isDisabled).map(team => ({ ...team, score: calculateTeamTotalScore(team.id, state.challenges, state.solves), solveCount: state.solves.filter(s => s.teamId === team.id).length })).sort((a, b) => b.score - a.score), [state]);
|
|
return (
|
|
<div className="max-w-5xl mx-auto py-12 px-6">
|
|
<div className="mb-12 border-b-4 border-[#ff0000] pb-6 flex justify-between items-end">
|
|
<div>
|
|
<h2 className="text-6xl font-black italic text-white uppercase tracking-tighter">SCOREBOARD</h2>
|
|
</div>
|
|
<Link to="/matrix" className="text-[10px] font-black text-[#ff0000] hover:underline flex items-center gap-1 uppercase tracking-widest"><Table size={12}/> View_Matrix</Link>
|
|
</div>
|
|
<div className="hxp-border border-2 bg-black overflow-hidden">
|
|
<table className="w-full text-left">
|
|
<thead className="bg-[#333] text-[10px] font-black uppercase"><tr><th className="p-4">RANK</th><th className="p-4">TEAM_IDENTIFIER</th><th className="p-4 text-center">SOLVES</th><th className="p-4 text-right">TOTAL_POINTS</th></tr></thead>
|
|
<tbody className="divide-y divide-white/10">
|
|
{rankings.map((team, idx) => (
|
|
<tr key={team.id} className="hover:bg-white/5 group">
|
|
<td className="p-4 font-black">
|
|
<div className={`w-8 h-8 flex items-center justify-center font-black italic text-lg ${idx === 0 ? 'bg-[#ffaa00] text-black rotate-[-12deg]' : idx === 1 ? 'bg-slate-400 text-black rotate-[6deg]' : idx === 2 ? 'bg-[#cd7f32] text-black rotate-[-6deg]' : 'text-slate-500'}`}>{idx + 1}</div>
|
|
</td>
|
|
<td className="p-4 font-black italic text-xl text-white group-hover:text-[#bf00ff] transition-colors">{team.name}</td>
|
|
<td className="p-4 text-center font-black text-[#bf00ff]">{team.solveCount}</td>
|
|
<td className="p-4 text-right text-2xl font-black text-white italic">{team.score}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const ScoreMatrix: React.FC = () => {
|
|
const { state } = useCTF();
|
|
|
|
const sortedTeams = useMemo(() => {
|
|
return state.teams
|
|
.filter(t => !t.isAdmin && !t.isDisabled)
|
|
.map(t => ({ ...t, score: calculateTeamTotalScore(t.id, state.challenges, state.solves) }))
|
|
.sort((a, b) => b.score - a.score);
|
|
}, [state]);
|
|
|
|
const sortedChallenges = useMemo(() => {
|
|
return [...state.challenges].sort((a, b) => (b.solves?.length || 0) - (a.solves?.length || 0));
|
|
}, [state.challenges]);
|
|
|
|
if (state.solves.length === 0) {
|
|
return (
|
|
<div className="max-w-4xl mx-auto py-32 px-6">
|
|
<div className="mb-12 border-b-4 border-[#00ff00] pb-6 flex justify-between items-end">
|
|
<h2 className="text-6xl font-black italic tracking-tighter text-white uppercase leading-none">SCORE_MATRIX</h2>
|
|
<Link to="/scoreboard" className="text-[10px] font-black text-[#00ff00] hover:underline flex items-center gap-1 uppercase tracking-widest"><Trophy size={12}/> View_Rankings</Link>
|
|
</div>
|
|
<div className="text-center hxp-border border-4 p-16 bg-black">
|
|
<SearchX className="w-20 h-20 text-slate-700 mx-auto mb-6" />
|
|
<h2 className="text-4xl font-black italic mb-4 uppercase text-white tracking-tighter">No solves yet!</h2>
|
|
<p className="text-slate-500 font-bold tracking-widest uppercase mb-8">The matrix is currently empty. Be the first to solve a challenge!</p>
|
|
<Link to="/challenges">
|
|
<Button className="px-12 py-4 text-lg">Go to Challenges</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full overflow-x-auto py-12 px-6 custom-scrollbar">
|
|
<div className="mb-12 border-b-4 border-[#00ff00] pb-6 max-w-6xl mx-auto flex justify-between items-end">
|
|
<h2 className="text-6xl font-black italic tracking-tighter text-white uppercase leading-none">SCORE_MATRIX</h2>
|
|
<Link to="/scoreboard" className="text-[10px] font-black text-[#00ff00] hover:underline flex items-center gap-1 uppercase tracking-widest"><Trophy size={12}/> View_Rankings</Link>
|
|
</div>
|
|
<div className="inline-block min-w-full bg-black hxp-border overflow-hidden">
|
|
<table className="border-collapse w-full">
|
|
<thead>
|
|
<tr>
|
|
<th className="sticky left-0 z-20 bg-black p-4 text-[10px] font-black uppercase text-slate-500 border-b-2 border-r-2 border-[#333] whitespace-nowrap min-w-[200px]">Team / Challenge</th>
|
|
{sortedChallenges.map(c => {
|
|
const diffColor = c.difficulty === 'Low' ? 'text-[#00ff00]' : c.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
|
|
return (
|
|
<th key={c.id} className="p-0 border-b-2 border-r-2 border-[#333] min-w-[80px] align-bottom relative group/col">
|
|
{/* Vertical text container using writing-mode so it stretches cell height automatically */}
|
|
<div className="flex flex-col items-center justify-end pb-12 pt-8">
|
|
<span className={`[writing-mode:vertical-rl] rotate-180 whitespace-nowrap text-[11px] font-black tracking-[0.2em] transition-colors uppercase ${diffColor} group-hover/col:brightness-125`}>
|
|
{c.title}
|
|
</span>
|
|
</div>
|
|
{/* Solve count tag at the bottom of the header cell */}
|
|
<div className="absolute bottom-1 left-0 right-0 text-[8px] text-slate-600 font-bold uppercase text-center py-1 border-t border-[#333] bg-black/50">
|
|
{(c.solves || []).length} SOLVES
|
|
</div>
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedTeams.map(team => (
|
|
<tr key={team.id} className="hover:bg-white/5 group">
|
|
<td className="sticky left-0 z-10 bg-black p-3 font-black italic text-sm border-r-2 border-b-2 border-[#333] whitespace-nowrap group-hover:text-[#bf00ff]">
|
|
{team.name} <span className="text-white not-italic opacity-40 ml-1 text-[10px] tracking-widest">({team.score})</span>
|
|
</td>
|
|
{sortedChallenges.map(c => {
|
|
const solve = state.solves.find(s => s.challengeId === c.id && s.teamId === team.id);
|
|
if (!solve) {
|
|
return (
|
|
<td key={c.id} className="p-0 border-r-2 border-b-2 border-[#333] text-center">
|
|
<div className="w-full h-12 flex items-center justify-center text-[#1a1a1a]">•</div>
|
|
</td>
|
|
);
|
|
}
|
|
|
|
// Find rank for the medal and bonus
|
|
const challengeSolves = state.solves
|
|
.filter(s => s.challengeId === c.id)
|
|
.sort((a, b) => a.timestamp - b.timestamp);
|
|
const rank = challengeSolves.findIndex(s => s.teamId === team.id);
|
|
|
|
// Calculate point gain for this specific solve
|
|
const baseValue = calculateChallengeValue(
|
|
c.initialPoints,
|
|
c.minimumPoints || 0,
|
|
c.decaySolves || 1,
|
|
(c.solves || []).length
|
|
);
|
|
const bonus = Math.floor(c.initialPoints * getFirstBloodBonusFactor(rank));
|
|
const totalPoints = baseValue + bonus;
|
|
|
|
return (
|
|
<td key={c.id} className="p-0 border-r-2 border-b-2 border-[#333] text-center">
|
|
<div
|
|
className={`w-full h-12 flex flex-col items-center justify-center ${rank === 0 ? 'bg-[#ffaa00]/10' : rank === 1 ? 'bg-slate-400/10' : rank === 2 ? 'bg-[#cd7f32]/10' : 'bg-[#00ff00]/5'}`}
|
|
title={rank === 0 ? "First Blood!" : rank === 1 ? "Second Solver" : rank === 2 ? "Third Solver" : "Solved"}
|
|
>
|
|
<div className="flex items-center justify-center">
|
|
{rank === 0 ? (
|
|
<Medal size={16} className="text-[#ffaa00]" />
|
|
) : rank === 1 ? (
|
|
<Medal size={16} className="text-slate-400" />
|
|
) : rank === 2 ? (
|
|
<Medal size={16} className="text-[#cd7f32]" />
|
|
) : (
|
|
<CheckCircle2 size={14} className="text-[#00ff00]" />
|
|
)}
|
|
</div>
|
|
<span className="text-[9px] font-black text-white/70 mt-1 leading-none">{totalPoints}</span>
|
|
</div>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|