ShyyTAUI/src/routes/tournaments/[tournamentguid]/matches/[matchGuid]/+page.svelte
2025-10-18 18:31:15 +02:00

1720 lines
No EOL
59 KiB
Svelte

<script lang="ts">
import { TABotTokenStore, TAServerPort, TAServerUrl, authTokenStore, client } from "$lib/stores";
import { onMount, onDestroy } from "svelte";
import { bkAPIUrl } from '$lib/config.json';
import Notification from '$lib/components/notifications/Popup.svelte';
//@ts-ignore
import {
Match,
Tournament,
TAClient,
Response_ResponseType,
User,
RealtimeScore,
Push_SongFinished,
Map,
User_DownloadStates,
User_PlayStates,
Tournament_TournamentSettings_Pool,
Command_ModifyGameplay_Modifier
} from 'moons-ta-client';
import { writable } from "svelte/store";
import { goto } from "$app/navigation";
import { fetchMapByLevelId, fetchMapsByLevelIds, type BeatSaverMap } from "$lib/services/beatsaver.js";
import SongQueue from "$lib/components/modules/SongQueue.svelte";
import EditMap from "$lib/components/popups/EditMap.svelte";
import Popup from "$lib/components/notifications/Popup.svelte";
import PreviouslyPlayedSongs from "$lib/components/modules/PreviouslyPlayedSongs.svelte";
import ResultsForMap from "$lib/components/popups/ResultsForMap.svelte";
import AddMapsFromTAPool from "$lib/components/popups/AddMapsFromTAPool.svelte";
import ModifyGameplay from "$lib/components/popups/ModifyGameplay.svelte";
// Types
import {
type CustomMap,
type RealTimeScoreForPlayers,
type PreviousResults,
type ButtonConfig,
type ColorPreset,
MapDifficulty,
type CustomTAMapPool
} from "$lib/interfaces/match.interfaces";
import AddMapsFromFile from "$lib/components/popups/AddMapsFromFile.svelte";
export let data;
// Route params
const tournamentGuid: string = data.tournamentGuid;
const matchGuid: string = data.matchGuid;
// Core state
let tournament: Tournament | undefined;
let match: Match | undefined;
let isLoading = false;
let error: string | null = null;
let matchPlayers: User[] = [];
let currentSong: any = null;
let currentSongData: BeatSaverMap;
let isMatchActive = false;
let streamSyncEnabled = false;
// Song queue and results
let upcomingQueueMaps: CustomMap[] = [];
let realTimeScores: RealTimeScoreForPlayers[] = [];
let previousMatchResults: PreviousResults[] = [];
let showingResultsData: PreviousResults;
// Modal states
let showKickConfirmModal = false;
let showBackToMenuModal = false;
let showExitMatchModal = false;
let playerToKick: User | null = null;
let showCannotExitPage: boolean = false;
let modalMessage: string = "";
let modalIcon: string = "danger";
let modalIconColor: ColorPreset = 'error';
let modalButtons: ButtonConfig[] = [];
let modalCustomImage: string = "";
let modalAutoClose: number = 0;
// Map editing states
let showMapModal: boolean = false;
let currentlyEditingMap: CustomMap | null = null;
// Song loading states
let loadingSongForPlayers: boolean = false;
let failedToLoadSongForPlayers: boolean = false;
// Results popup states
let showResultsPopup: boolean = false;
let dontShowResultsPopupForThisSong: boolean = false;
let currentlyShowingOldMapResults: boolean = false;
// Add maps from TA pool modal state
let taPools: Tournament_TournamentSettings_Pool[] = [];
let customTAPools: CustomTAMapPool[] = [];
let showAddFromTAPoolPopup: boolean = false;
let allowAddFromTAPool: boolean = true;
interface PlayerModState {
platformId: string;
isColorSwitched: boolean;
isHandSwitched: boolean;
isBlueDisabled: boolean;
isRedDisabled: boolean;
}
let playerModificationStates: PlayerModState[] = []
// User editing state
let isModifyGameplayPopupVisible: boolean = false;
let editingPlayerPlatformId: string, editingPlayerName: string, editingPlayerPfp: string = "";
// Active song tracking
const activeSongPlayers = new Set<`${string}-${string}`>(); // Format: "userGuid-levelId"
// Reactive statements
$: isAnyPlayerPlaying = matchPlayers.some(player => (player.playState as any) === User_PlayStates.InGame);
$: if(isAnyPlayerPlaying && currentSong) {
matchPlayers
.filter(x => x.playState == User_PlayStates.InGame)
.forEach(player => {
const playerKey: string = `${player.guid}-${currentSong.beatmap.levelId.toLowerCase()}`;
if (!activeSongPlayers.has(playerKey as any)) {
activeSongPlayers.add(playerKey as any);
}
});
}
if ($authTokenStore) {
const cleanToken = $authTokenStore.replace('Bearer ', '');
client.setAuthToken(cleanToken);
}
// Show notification function
function showNotification(message: string, type: 'warning' | 'error' | 'success' = 'warning') {
// Your existing notification function
console.log(`${type}: ${message}`);
}
function handleExitResultsPopupClicked() {
showResultsPopup = false;
if(!currentlyShowingOldMapResults) {
dontShowResultsPopupForThisSong = true;
}
currentlyShowingOldMapResults = false;
}
function showResultsPopupWithOldScores(event: CustomEvent) {
const oldData: PreviousResults = event.detail.map;
currentlyShowingOldMapResults = true;
showingResultsData = oldData;
showResultsPopup = true;
dontShowResultsPopupForThisSong = false;
}
// Handle back to matches with confirmation if needed
async function handleBackToMatches(endMatch: boolean) {
if(match!.leader !== client.stateManager.getSelfGuid()) {
await removeSelfFromMatch();
goto(`/tournaments/${tournamentGuid}`);
}
if(endMatch) {
if (isAnyPlayerPlaying) {
showExitMatchModal = true;
modalMessage = `Players are currently in-game. Exiting will close the match for all players. Continue?`;
modalIcon = 'warning';
modalButtons = [
{
text: `Close (NO)`,
type: 'primary',
action: () => showBackToMenuModal = false,
icon: 'close'
},
{
text: `Yes (YES)`,
type: 'secondary',
action: async() => await confirmExitMatch(),
icon: 'backspace'
}
];
} else {
await removeSelfFromMatch();
await client.deleteMatch(tournamentGuid, matchGuid);
goto(`/tournaments/${tournamentGuid}`);
}
}
showExitMatchModal = true;
modalMessage = `This should not cause issues as it should not end the match. But TA can be weird sometimes. THIS SHOULD BE SAFE. Do you want to continue?`;
modalIcon = 'info';
modalIconColor = 'info';
modalButtons = [
{
text: `Close (NO)`,
type: 'primary',
action: () => showBackToMenuModal = false,
icon: 'close'
},
{
text: `Yes (YES)`,
type: 'secondary',
action: async() => await confirmExitMatch(),
icon: 'backspace'
}
];
}
// Confirm exit match
function confirmExitMatch() {
showExitMatchModal = false;
showNotification('Match will be closed', 'warning');
goto(`/tournaments/${tournamentGuid}`);
}
// Handle kick player
function handleKickPlayer(player: User) {
playerToKick = player;
modalMessage = `Are you sure you want to kick ${player.name} from the match?`;
modalIcon = 'warning';
modalButtons = [
{
text: `Close (NO)`,
type: 'primary',
action: () => showKickConfirmModal = false,
icon: 'close'
},
{
text: `Kick ${player.name} (YES)`,
type: 'secondary',
action: async() => await confirmKickPlayer(),
icon: 'sports_martial_arts'
}
];
showKickConfirmModal = true;
}
// Confirm kick player
async function confirmKickPlayer() {
if (!playerToKick) {
showKickConfirmModal = false;
return;
}
try {
// Implementation for kicking player from match
await kickPlayerFromMatch(playerToKick.guid);
showNotification(`${playerToKick.name} has been kicked from the match`, 'success');
await fetchMatchData();
} catch (err) {
console.error('Error kicking player:', err);
showNotification('Failed to kick player', 'error');
}
showKickConfirmModal = false;
playerToKick = null;
}
// Start song for all players
async function startSong() {
try {
if (!currentSong) {
showNotification('No song selected', 'warning');
return;
}
await startSongForMatch();
isMatchActive = true;
} catch (err) {
console.error('Error starting song:', err);
showNotification('Failed to start song', 'error');
}
}
// Start song with stream sync
async function startSongWithStreamSync() {
if (!streamSyncEnabled) return;
try {
await startSongWithStreamSyncForMatch();
showNotification('Song started with stream sync', 'success');
isMatchActive = true;
} catch (err) {
console.error('Error starting song with stream sync:', err);
showNotification('Failed to start song with stream sync', 'error');
}
}
// Back to menu
function handleBackToMenu() {
showBackToMenuModal = true;
modalMessage = `This will send all players back to the menu and stop the current song. Continue?`;
modalIcon = 'warning';
modalButtons = [
{
text: `Close (NO)`,
type: 'primary',
action: () => showBackToMenuModal = false,
icon: 'close'
},
{
text: `Yes (YES)`,
type: 'secondary',
action: async() => await confirmBackToMenu(),
icon: 'backspace'
}
];
}
// Confirm back to menu
async function confirmBackToMenu() {
try {
await sendPlayersBackToMenu();
showNotification('All players sent back to menu', 'success');
isMatchActive = false;
currentSong = null;
} catch (err) {
console.error('Error sending players back to menu:', err);
showNotification('Failed to send players back to menu', 'error');
}
showBackToMenuModal = false;
}
// Return status color based on player status
function getStatusColor(playState: User_PlayStates, downloadState: User_DownloadStates): string {
if(downloadState == User_DownloadStates.Downloading) return "var(--danger-color)";
if(downloadState == User_DownloadStates.DownloadError) return "var(--danger-color)";
if(playState == User_PlayStates.InGame) return "var(--danger-color)";
switch (playState) {
case User_PlayStates.WaitingForCoordinator:
return "#FCD34D"; // Yellow
case User_PlayStates.InMenu:
return "#10B981"; // Green
default:
return "var(--text-secondary)";
}
}
// Handle match events
async function handleMatchUpdated(params: [Match, Tournament]) {
console.log("Match updated:", params);
if (params[0].guid === matchGuid) {
match = params[0];
await refreshMatchDetails(params[0]);
}
}
function handleMatchDeleted(params: [Match, Tournament]) {
console.log("Match deleted:", params);
if (params[0].guid === matchGuid) {
goto(`/tournaments/${tournamentGuid}`);
}
}
function handleSongChanged(songInfo: any) {
console.log("Song changed:", songInfo);
currentSong = songInfo;
}
async function addSelfToMatch() {
if(!match?.associatedUsers.includes(client.stateManager.getSelfGuid())) {
const response = await client.addUserToMatch(tournamentGuid, matchGuid, client.stateManager.getSelfGuid());
console.log("Added Self to match!", response);
}
}
async function removeSelfFromMatch() {
if(match?.associatedUsers.includes(client.stateManager.getSelfGuid())) {
const response = await client.removeUserFromMatch(tournamentGuid, matchGuid, client.stateManager.getSelfGuid());
console.log("Removed Self from match!", response);
}
}
async function refreshMatchDetails(match: Match) {
// Get match players
matchPlayers = match.associatedUsers
.map(userGuid => client.stateManager.getUser(tournamentGuid, userGuid))
.filter(user => user && user.clientType === 0) as User[];
// Get current song if available
currentSong = match.selectedMap?.gameplayParameters || null;
if(currentSong) {
try {
const tempMapData = await fetchMapByLevelId(currentSong.beatmap.levelId);
if(tempMapData) {
currentSongData = tempMapData;
if(!upcomingQueueMaps.some(x => x.taData.gameplayParameters?.beatmap?.levelId.toUpperCase() == currentSong.beatmap.levelId.toUpperCase())) {
upcomingQueueMaps = [
{
taData: match.selectedMap!,
beatsaverData: tempMapData!
},
...upcomingQueueMaps
];
}
}
} catch (error) {
console.log('Current song beatsaver data fetch failed');
}
}
const tournament = client.stateManager.getTournament(tournamentGuid);
if(!tournament!.settings!.enablePools) {
allowAddFromTAPool = false;
return;
}
taPools = tournament!.settings!.pools;
// First, collect all unique level IDs from all pools
const allLevelIds = taPools.flatMap(pool =>
pool.maps.map(map => map.gameplayParameters!.beatmap!.levelId)
);
// Remove duplicates to avoid fetching the same map multiple times
const uniqueLevelIds = [...new Set(allLevelIds)];
// Fetch all maps data in one go (your function handles chunking internally)
const allMapBeatSaverDatas = await fetchMapsByLevelIds(uniqueLevelIds);
// Create a lookup object for quick access
const mapDataLookup: { [levelId: string]: any } = {};
allMapBeatSaverDatas.forEach(mapData => {
if (mapData && mapData.versions[0].hash) {
mapDataLookup[mapData.versions[0].hash] = mapData;
}
});
// Now process each pool using the pre-fetched data
customTAPools = await Promise.all(
taPools.map(async (pool) => {
const poolCustomMaps: CustomMap[] = pool.maps
.map(map => {
const levelId = map.gameplayParameters!.beatmap!.levelId;
const beatsaverData = mapDataLookup[(levelId.toLowerCase().replace('custom_level_', ''))];
if (!beatsaverData) return null;
return {
taData: map,
beatsaverData: beatsaverData
};
})
.filter(result => result !== null) as CustomMap[];
return {
name: pool.name,
guid: pool.guid,
image: pool.image,
maps: poolCustomMaps
};
})
);
}
async function fetchMatchData() {
if (!$authTokenStore) {
window.location.href = '/discordAuth';
return;
}
isLoading = true;
error = null;
try {
if(!client.isConnected) {
const connectResult = await client.connect($TAServerUrl, $TAServerPort);
if (connectResult.details.oneofKind === "connect" && connectResult.type === Response_ResponseType.Fail) {
throw new Error(connectResult.details.connect.message);
}
}
// Get tournament and match data
tournament = client.stateManager.getTournament(tournamentGuid);
match = client.stateManager.getMatch(tournamentGuid, matchGuid);
if(!match?.associatedUsers.includes(client.stateManager.getSelfGuid())) {
await addSelfToMatch();
}
if (!match) {
throw new Error('Match not found');
}
await refreshMatchDetails(match);
} catch (err) {
console.error('Error fetching match data:', err);
error = err instanceof Error ? err.message : 'An unknown error occurred';
} finally {
isLoading = false;
}
}
async function kickPlayerFromMatch(playerGuid: string) {
const response = await client.removeUserFromMatch(tournamentGuid, matchGuid, playerGuid);
console.log('Kicking player:', playerGuid);
console.log("removeResponse", response);
}
async function startSongForMatch() {
if (!currentSong) return;
showResultsPopup = false;
dontShowResultsPopupForThisSong = false;
const levelId = currentSong.beatmap.levelId;
const playingPlayerIds = matchPlayers.map(player => player.guid);
// Clear any existing entries for this levelId
Array.from(activeSongPlayers)
.filter(key => key.endsWith(`-${levelId}`))
.forEach(key => activeSongPlayers.delete(key));
// Add all current players for this song
playingPlayerIds.forEach(playerGuid => {
activeSongPlayers.add(`${playerGuid}-${levelId.toLowerCase()}`);
});
const response = await client.playSong(tournamentGuid, currentSong, playingPlayerIds);
console.log('startMap', response);
}
async function startSongWithStreamSyncForMatch() {
// Implement start song with stream sync logic
console.log('Starting song with stream sync');
}
async function sendPlayersBackToMenu() {
const backToMenuResponse = await client.returnToMenu(tournamentGuid, matchPlayers.filter(x => x.playState == User_PlayStates.InGame).map(x => x.guid));
console.log("Sent players back to menu!", backToMenuResponse)
}
async function handleUserDisconnected(params: [User, Tournament]) {
if(matchPlayers.some(user => user.guid == params[0].guid)) {
console.log("User disconnected from match. User: ", params[0])
matchPlayers = matchPlayers.filter(x => x.guid !== params[0].guid);
}
}
function getTimeStringFromSeconds(seconds: number) {
let newString: string = "";
const minutes = Math.floor(seconds / 60);
if(minutes > 0) {
newString += `${minutes} minutes `;
}
const timeSeconds = (seconds - Math.floor(seconds / 60) * 60).toFixed(1);
if(!timeSeconds.startsWith("0")) {
newString += `${timeSeconds} seconds `;
}
return newString;
}
async function handleRealtimeScoreUpdate(rts: RealtimeScore) {
console.log(`Player ${matchPlayers.find(x => x.guid == rts.userGuid)?.name}: ${rts.accuracy}`, rts);
const player = matchPlayers.find(x => x.guid === rts.userGuid)!;
if(!player) return;
const index = realTimeScores.findIndex(x => x.player.guid === rts.userGuid);
if (index !== -1) {
// Update the existing entry
realTimeScores[index] = { player, recentScore: rts };
} else {
// Append only if it's a new player
realTimeScores = [
...realTimeScores,
{ player, recentScore: rts }
];
}
}
function getPlayerRtsScore(userGuid: string) {
let playerScore = realTimeScores.find(x => x.player.guid == userGuid);
if(!playerScore) {
playerScore = {
player: matchPlayers.find(x => x.guid == userGuid)!,
recentScore: {
userGuid: userGuid,
score: 0,
scoreWithModifiers: 0,
maxScore: 0,
maxScoreWithModifiers: 0,
combo: 0,
playerHealth: 0,
accuracy: 0,
songPosition: 0,
notesMissed: 0,
badCuts: 0,
bombHits: 0,
wallHits: 0,
maxCombo: 0
}
}
}
return playerScore;
}
function getMaxScore(songInfo: BeatSaverMap, characteristic: string = "standard", difficulty: string): number {
const diff = songInfo.versions[0].diffs.find(
(x) => {
const charMatch = x.characteristic.toLowerCase() === characteristic.toLowerCase();
// Normalize difficulty strings for comparison
const searchDiff = difficulty.toLowerCase() === "expert+" ? "expertplus" : difficulty.toLowerCase();
const diffDiff = x.difficulty.toLowerCase() === "expert+" ? "expertplus" : x.difficulty.toLowerCase();
return charMatch && diffDiff === searchDiff;
}
);
return diff?.maxScore ?? 100000;
}
// Method to convert difficulty number to string
function getDifficultyAsString(difficulty: number): string {
return MapDifficulty[difficulty] || "ExpertPlus";
}
// Main accuracy calculation logic
function calculateAccuracy(score: Push_SongFinished, mapWithSongInfo: CustomMap): number {
const maxScore = getMaxScore(
mapWithSongInfo.beatsaverData,
score.beatmap?.characteristic?.serializedName ?? "Standard",
getDifficultyAsString(score.beatmap?.difficulty ?? 4) || "ExpertPlus",
);
const accuracy = parseFloat(((score.score / maxScore) * 100).toFixed(2));
return accuracy;
}
async function handleSongFinished(rts: Push_SongFinished) {
if (rts.matchId !== matchGuid) return;
const levelId = rts.beatmap?.levelId;
if (!levelId) return;
// Remove player from active song tracking
activeSongPlayers.delete(`${rts.player!.guid}-${levelId.toLowerCase()}`);
const map = upcomingQueueMaps.find(x =>
x.taData.gameplayParameters!.beatmap!.levelId.toUpperCase() === levelId.toUpperCase()
);
if (!map) {
console.log('Received a score for unknown map', rts);
return;
}
let newRts: Push_SongFinished = {...rts, accuracy: 0};
newRts.accuracy = calculateAccuracy(rts, map);
// Check if any players are still playing this song
const playersStillPlaying = Array.from(activeSongPlayers).some(key => key.endsWith(`-${levelId.toLowerCase()}`));
console.log("Players still playing:", playersStillPlaying)
const completionType = playersStillPlaying
? 'Still Awaiting Scores'
: 'Completed';
console.log("CompletionType", completionType)
// Find or create the result record
const existingRecordIndex = previousMatchResults.findIndex(x =>
x.completionType === 'Still Awaiting Scores' &&
x.taData.gameplayParameters?.beatmap?.levelId.toUpperCase() === levelId.toUpperCase()
);
console.log("existingRecordIndex", existingRecordIndex)
if (existingRecordIndex === -1) {
console.log("creating new record")
previousMatchResults = [
{
taData: map.taData,
beatsaverData: map.beatsaverData,
completionType,
scores: [newRts]
},
...previousMatchResults
];
} else {
const existingRecord = previousMatchResults[existingRecordIndex];
previousMatchResults = [
{
taData: map.taData,
beatsaverData: map.beatsaverData,
completionType,
scores: [...existingRecord.scores, newRts]
},
...previousMatchResults.filter((_, i) => i !== existingRecordIndex)
];
}
playerModificationStates = [];
showingResultsData = previousMatchResults[0];
showResultsPopup = true;
}
async function handleMapUpdated(event: CustomEvent) {
const map = upcomingQueueMaps.find(x => x.taData.guid == event.detail.map.guid);
if(!map) return;
upcomingQueueMaps = [
...upcomingQueueMaps.filter(x => x.taData.guid !== event.detail.map.guid),
{
taData: (event.detail.map as Map),
beatsaverData: map.beatsaverData
}
];
}
async function handleMapAdded(event: CustomEvent) {
const beatsaverData: BeatSaverMap | null = await fetchMapByLevelId(event.detail.map.gameplayParameters.beatmap.levelId);
if(!beatsaverData) {
console.log('Failed to fetch beatsaver map data for new map, aborting map addition!');
return;
}
const newMap: CustomMap = {
taData: event.detail.map,
beatsaverData: beatsaverData
};
upcomingQueueMaps = [
...upcomingQueueMaps,
newMap
];
}
function handleShowAddMapsFromTAPool() {
showAddFromTAPoolPopup = true;
}
function handleCloseAddMapsFromTAPool() {
showAddFromTAPoolPopup = false;
}
function handleAddMapsFromTAPool(event: CustomEvent) {
showAddFromTAPoolPopup = false;
const mapsToAdd: CustomMap[] = event.detail.maps;
upcomingQueueMaps = [
...mapsToAdd,
...upcomingQueueMaps
];
}
function closeEditMapModal() {
showMapModal = false;
}
async function handleAddMap() {
currentlyEditingMap = null;
showMapModal = true;
}
async function handleEditMap(event: CustomEvent) {
currentlyEditingMap = event.detail.map;
showMapModal = true;
}
async function handleRemoveMapFromQueue(event: CustomEvent) {
upcomingQueueMaps = [
...upcomingQueueMaps.filter(x => x.taData.guid !== event.detail.map.taData.guid)
];
}
async function handleLoadMap(event: CustomEvent) {
const map: CustomMap = event.detail.map;
loadingSongForPlayers = false;
failedToLoadSongForPlayers = false;
dontShowResultsPopupForThisSong = false;
let playingPlayerIds = matchPlayers
.filter(player => player.playState !== User_PlayStates.InGame)
.map(player => player.guid);
if(!playingPlayerIds || playingPlayerIds.length == 0) {
playingPlayerIds = [];
}
loadingSongForPlayers = true;
const matchResponse = await client.setMatchMap(tournamentGuid, matchGuid, map.taData);
if(matchResponse.type == Response_ResponseType.Fail) return;
const loadResponse = await client.loadSong(tournamentGuid, map.taData.gameplayParameters!.beatmap!.levelId.toUpperCase().replace('CUSTOM_LEVEL_', 'custom_level_'), playingPlayerIds, 3 * 60 * 1000);
failedToLoadSongForPlayers = false;
const failedLoads = loadResponse.filter(x => x.response.type == Response_ResponseType.Fail);
if(failedLoads.length > 0) {
failedToLoadSongForPlayers = true;
showNotification('Failed to load song as one or more players failed to load! Please try again', 'error');
} else {
loadingSongForPlayers = false;
showNotification('Successfully loaded song!', 'success');
}
console.log("songLoaded", loadResponse);
}
async function handleUserUpdated(params: [User, Tournament]) {
if(params[1].guid !== tournamentGuid) return;
const userIndex = matchPlayers.findIndex(x => x.guid === params[0].guid);
if(userIndex !== -1) {
matchPlayers = [
...matchPlayers.slice(0, userIndex),
params[0],
...matchPlayers.slice(userIndex + 1)
];
} else {
matchPlayers = [...matchPlayers, params[0]];
}
}
async function handleSetGameplayModifiersForUsersVerbose(userId: string, modifiers: number) {
try {
const results: any[] = [];
const isModifierEnabled = (modifier: Command_ModifyGameplay_Modifier): boolean => {
return (modifiers & (1 << modifier)) !== 0;
};
let flipColorUserIds: string[] = [];
let flipHandUserIds: string[] = [];
let flipBlueUserIds: string[] = [];
let flipRedUserIds: string[] = [];
// Determine the desired state from modifiers
const desiredColorSwitched = isModifierEnabled(Command_ModifyGameplay_Modifier.InvertColors);
const desiredHandSwitched = isModifierEnabled(Command_ModifyGameplay_Modifier.InvertHandedness);
const desiredBlueDisabled = isModifierEnabled(Command_ModifyGameplay_Modifier.DisableBlueNotes);
const desiredRedDisabled = isModifierEnabled(Command_ModifyGameplay_Modifier.DisableRedNotes);
const userObject = playerModificationStates.find(x => x.platformId == userId);
if(!userObject) {
// User doesn't exist in state, so current state is all false
// Send toggle packets for any modifiers that should be enabled
if(desiredColorSwitched) flipColorUserIds = [...flipColorUserIds, userId];
if(desiredHandSwitched) flipHandUserIds = [...flipHandUserIds, userId];
if(desiredBlueDisabled) flipBlueUserIds = [...flipBlueUserIds, userId];
if(desiredRedDisabled) flipRedUserIds = [...flipRedUserIds, userId];
// Add new user with desired state
playerModificationStates = [...playerModificationStates, {
platformId: userId,
isColorSwitched: desiredColorSwitched,
isHandSwitched: desiredHandSwitched,
isBlueDisabled: desiredBlueDisabled,
isRedDisabled: desiredRedDisabled
}];
} else {
// User exists, check which states need to change
if(desiredColorSwitched !== userObject.isColorSwitched) flipColorUserIds = [...flipColorUserIds, userId];
if(desiredHandSwitched !== userObject.isHandSwitched) flipHandUserIds = [...flipHandUserIds, userId];
if(desiredBlueDisabled !== userObject.isBlueDisabled) flipBlueUserIds = [...flipBlueUserIds, userId];
if(desiredRedDisabled !== userObject.isRedDisabled) flipRedUserIds = [...flipRedUserIds, userId];
// Update user state to desired state
playerModificationStates = [...playerModificationStates.filter(x => x.platformId != userId), {
platformId: userId,
isColorSwitched: desiredColorSwitched,
isHandSwitched: desiredHandSwitched,
isBlueDisabled: desiredBlueDisabled,
isRedDisabled: desiredRedDisabled
}];
}
// Only send packets if there are users to modify
if(flipColorUserIds.length > 0) {
console.log('Inverting colors for users:', flipColorUserIds);
const response1 = await client.flipColors(tournamentGuid, flipColorUserIds);
results.push({ type: 'InvertColors', response: response1 });
}
if(flipHandUserIds.length > 0) {
console.log('Inverting handedness for users:', flipHandUserIds);
const response2 = await client.flipHands(tournamentGuid, flipHandUserIds);
results.push({ type: 'InvertHandedness', response: response2 });
}
if(flipBlueUserIds.length > 0) {
console.log('Disabling blue notes for users:', flipBlueUserIds);
const response3 = await client.disableBlueNotes(tournamentGuid, flipBlueUserIds);
results.push({ type: 'DisableBlueNotes', response: response3 });
}
if(flipRedUserIds.length > 0) {
console.log('Disabling red notes for users:', flipRedUserIds);
const response4 = await client.disableRedNotes(tournamentGuid, flipRedUserIds);
results.push({ type: 'DisableRedNotes', response: response4 });
}
console.log(`Applied ${results.length} modifiers successfully`);
console.log("Modifier apply res", results);
return results;
} catch (error) {
console.error('Error applying gameplay modifiers:', error);
throw error;
}
}
async function handleModifyGameplayForPlayer(player: User) {
editingPlayerName = player.name;
editingPlayerPfp = player.discordInfo?.avatarUrl || "";
editingPlayerPlatformId = player.guid;
isModifyGameplayPopupVisible = true;
}
async function handleSendModifyGameplay(event: CustomEvent) {
const { platformId, modifiers } = event.detail;
await handleSetGameplayModifiersForUsersVerbose(platformId, modifiers);
}
const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
event.preventDefault(); // Not necessary in some browsers but safe
};
onMount(async() => {
if ($authTokenStore) {
// Fetch the match data that we need to display
await fetchMatchData();
// Using stateManger listen to global changes to our match
client.stateManager.on('matchDeleted', handleMatchDeleted);
client.stateManager.on('matchUpdated', handleMatchUpdated);
client.stateManager.on('userDisconnected', handleUserDisconnected);
client.stateManager.on('userUpdated', handleUserUpdated);
client.on('realtimeScore', handleRealtimeScoreUpdate);
client.on('songFinished', handleSongFinished);
window.addEventListener('beforeunload', beforeUnloadHandler);
} else {
window.location.href = "/discordAuth"
}
});
onDestroy(() => {
// Remove the listeners that were added on mount
client.stateManager.removeListener('matchUpdated', handleMatchUpdated);
client.stateManager.removeListener('matchDeleted', handleMatchDeleted);
client.stateManager.removeListener('userDisconnected', handleUserDisconnected);
client.stateManager.removeListener('userUpdated', handleUserUpdated);
client.removeListener('realtimeScore', handleRealtimeScoreUpdate);
client.removeListener('songFinished', handleSongFinished);
window.removeEventListener('beforeunload', beforeUnloadHandler);
});
</script>
<div class="layout">
<main class="match-dashboard">
<div class="content-area">
<div class="content-header">
<div class="header-left">
<button
class="back-button {isAnyPlayerPlaying ? 'disabled' : ''}"
on:click={() => handleBackToMatches(false)}
disabled={isAnyPlayerPlaying}
>
<span class="material-icons">arrow_back</span>
Back to Matches
{#if isAnyPlayerPlaying}
<span class="material-icons warning-icon">warning</span>
{/if}
</button>
<button class="kick-button" on:click={() => handleBackToMatches(true)}>
<span class="material-icons">person_remove</span>
End Match
</button>
<div class="match-info">
<h2>Match: {match?.guid || 'Undefined'}</h2>
<p class="tournament-name">{tournament?.settings?.tournamentName}</p>
</div>
</div>
<div class="actions">
<button class="action-button refresh" on:click={fetchMatchData}>
<span class="material-icons">refresh</span>
</button>
</div>
</div>
{#if isLoading}
<div class="loading">
<div class="spinner"></div>
<p>Loading match data...</p>
</div>
{:else if error}
<div class="error-container">
<span class="material-icons">error_outline</span>
<p>{error}</p>
<button class="retry-button" on:click={fetchMatchData}>Retry</button>
</div>
{:else}
<div class="match-content">
<!-- Current Song Section -->
<div class="song-section">
<h3>Current Song</h3>
{#if currentSong && currentSongData}
<div class="current-song">
<div class="song-cover">
<img src={currentSongData.versions[0].coverURL || '/default-song-cover.png'} alt="Song Cover" />
</div>
<div class="song-info">
<h4>{currentSong.beatmap.name || 'Unknown Song'}</h4>
<p>{currentSongData.metadata.songAuthorName || 'Unknown Artist'}</p>
</div>
</div>
{:else}
<div class="empty-state">
<span class="material-icons">music_note</span>
<p>No song selected</p>
</div>
{/if}
{#if failedToLoadSongForPlayers}
<div class="warning-box">
<span class="material-icons">warning</span>
<div>
<strong>FAILED TO LOAD SONG FOR ALL PLAYERS!</strong> You can still start the map, but it will not start for those whos state is "Downloading"!
</div>
</div>
{/if}
{#if loadingSongForPlayers}
<div class="warning-box">
<span class="material-icons">warning</span>
<div>
<strong>LOADING SONG FOR PLAYERS!</strong> You can still start the map, but it will not start for those whos state is "Downloading"!
</div>
</div>
{/if}
{#if isAnyPlayerPlaying}
<div class="warning-box">
<span class="material-icons">warning</span>
<div>
<strong>PLAYERS ARE IN GAME!</strong> Some players are currently in game. Please refrain from loading new songs, starting maps, or exiting this match. You may of course add new maps to the queue etc.
</div>
</div>
{/if}
<div class="song-controls">
<button class="action-button start-song" on:click={startSong} disabled={!currentSong}>
<span class="material-icons">play_arrow</span>
Start Song
</button>
<button
class="action-button start-sync {!streamSyncEnabled ? 'disabled' : ''}"
on:click={startSongWithStreamSync}
disabled={!streamSyncEnabled || !currentSong}
>
<span class="material-icons">sync</span>
Start with Stream Sync
</button>
{#if isAnyPlayerPlaying}
<button class="action-button back-to-menu" on:click={handleBackToMenu}>
<span class="material-icons">home</span>
Back to Menu
</button>
{/if}
</div>
</div>
<!-- Real Time Score Display -->
{#if matchPlayers.some(player => (player.playState) === User_PlayStates.InGame)}
<div class="rts-section">
<div class="players-list">
{#each realTimeScores as rtsPlayer}
<div class="player-card">
<div class="player-status" style="background-color: {getStatusColor(rtsPlayer.player.playState, rtsPlayer.player.downloadState)}"></div>
<img
src={rtsPlayer.player.discordInfo?.avatarUrl || "https://api.dicebear.com/7.x/identicon/svg?seed=" + rtsPlayer.player.guid}
alt="Player"
class="player-avatar"
/>
<div class="player-info">
<h4>{rtsPlayer.player.name} ({rtsPlayer.player.discordInfo?.username || 'Discord Unknown'})</h4>
<p class="player-status-text">{(rtsPlayer.recentScore.accuracy * 100).toFixed(2)}%</p>
<p class="player-status-text">{rtsPlayer.recentScore.notesMissed} Misses - {rtsPlayer.recentScore.badCuts} Bad Cuts - {rtsPlayer.recentScore.bombHits} Bomb Hits - {rtsPlayer.recentScore.wallHits} Wall Hits</p>
</div>
<div class="player-info">
<h4>Song Progress: {((rtsPlayer.recentScore.songPosition / currentSongData.metadata.duration) * 100).toFixed(2)}%</h4>
<p>{getTimeStringFromSeconds(rtsPlayer.recentScore.songPosition)} of {getTimeStringFromSeconds(currentSongData.metadata.duration)}</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Players Section -->
<div class="players-section">
<h3>Players ({matchPlayers.length})</h3>
{#if matchPlayers.length === 0}
<div class="empty-state">
<span class="material-icons">person_off</span>
<p>No players in match</p>
</div>
{:else}
<div class="players-list">
{#each matchPlayers as player}
<div class="player-card">
<div class="player-status" style="background-color: {getStatusColor(player.playState, player.downloadState)}"></div>
<img
src={player.discordInfo?.avatarUrl || "https://api.dicebear.com/7.x/identicon/svg?seed=" + player.guid}
alt="Player"
class="player-avatar"
/>
<div class="player-info">
<h4>{player.name} ({player.discordInfo?.username || 'Unknown'})</h4>
<p class="player-status-text">{User_DownloadStates[player.downloadState]}</p>
<p class="player-status-text">{User_PlayStates[player.playState]}</p>
</div>
<div style="display: inline-block; margin-right: 0.4rem;">
<button class="kick-button" on:click={() => handleKickPlayer(player)}>
<span class="material-icons">person_remove</span>
</button>
<button class="modify-button" on:click={() => handleModifyGameplayForPlayer(player)}>
<span class="material-icons">settings</span>
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Song Upcoming Queue Section -->
<div class="queue-section">
<SongQueue
bind:maps={upcomingQueueMaps}
on:addMap={handleAddMap}
on:editMap={handleEditMap}
on:removeMap={handleRemoveMapFromQueue}
on:songLoad={handleLoadMap}
on:addMapsFromTAPool={handleShowAddMapsFromTAPool}
/>
</div>
<!-- Previously Played Maps Section -->
<div class="queue-section">
<PreviouslyPlayedSongs
maps={previousMatchResults}
on:showMapDetails={showResultsPopupWithOldScores}
/>
</div>
</div>
{/if}
</div>
</main>
</div>
{#if showMapModal}
<EditMap
tournamentGuid={tournamentGuid}
map={currentlyEditingMap}
on:close={closeEditMapModal}
on:mapUpdated={handleMapUpdated}
on:mapAdded={handleMapAdded}
mode='playing'
/>
{/if}
<Popup
open={showKickConfirmModal || showBackToMenuModal || showExitMatchModal}
message={modalMessage}
icon={modalIcon}
iconColor={modalIconColor}
buttons={modalButtons}
customImage={modalCustomImage}
autoClose={modalAutoClose}
/>
<ResultsForMap
mapData={showingResultsData}
isVisible={showResultsPopup && !dontShowResultsPopupForThisSong}
on:close={handleExitResultsPopupClicked}
/>
{#if allowAddFromTAPool}
<AddMapsFromTAPool
isOpen={showAddFromTAPoolPopup}
mapPools={customTAPools}
on:close={handleCloseAddMapsFromTAPool}
on:addMaps={handleAddMapsFromTAPool}
/>
{/if}
<ModifyGameplay
bind:isVisible={isModifyGameplayPopupVisible}
playerName={editingPlayerName}
playerPfp={editingPlayerPfp}
playerPlatformId={editingPlayerPlatformId}
on:sendModifyGameplay={handleSendModifyGameplay}
/>
<style>
.match-dashboard {
display: flex;
height: calc(100vh - var(--navbar-height));
}
.content-area {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.back-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.back-button:hover:not(.disabled) {
background-color: var(--bg-secondary);
}
.back-button.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.warning-icon {
color: var(--danger-color);
font-size: 1rem;
}
.match-info h2 {
font-size: 1.75rem;
font-weight: 600;
margin: 0;
}
.tournament-name {
color: var(--text-secondary);
font-size: 0.875rem;
margin: 0;
}
.actions {
display: flex;
gap: 0.75rem;
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.action-button.refresh:hover {
background-color: var(--bg-secondary);
}
.action-button.start-song {
background-color: var(--accent-color);
}
.action-button.start-song:hover:not(:disabled) {
background-color: var(--accent-hover);
box-shadow: 0 0 10px var(--accent-glow);
}
.action-button.start-sync {
background-color: #8B5CF6;
}
.action-button.start-sync:hover:not(.disabled) {
background-color: #7C3AED;
box-shadow: 0 0 10px rgba(139, 92, 246, 0.5);
}
.action-button.back-to-menu {
background-color: #F59E0B;
}
.action-button.back-to-menu:hover {
background-color: #D97706;
box-shadow: 0 0 10px rgba(245, 158, 11, 0.5);
}
.action-button.disabled,
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Match Content Layout */
.match-content {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
gap: 1.5rem;
}
.song-section {
grid-column: 1 / -1;
}
.rts-section {
grid-column: 1 / -1;
}
.players-section {
grid-column: 1;
}
.queue-section {
grid-column: 2;
background-color: var(--bg-secondary);
border-radius: 0.75rem;
padding-top: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-height: 20rem;
overflow-y: scroll;
}
/* Section Styles */
.song-section, .players-section {
background-color: var(--bg-secondary);
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.song-section h3, .players-section h3, .queue-section h3 {
font-size: 1.25rem;
font-weight: 500;
margin-bottom: 1rem;
}
/* Current Song */
.current-song {
display: flex;
align-items: center;
gap: 1rem;
background-color: var(--bg-tertiary);
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.song-cover {
width: 4rem;
height: 4rem;
border-radius: 0.5rem;
overflow: hidden;
flex-shrink: 0;
}
.song-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.song-info h4 {
font-size: 1.125rem;
font-weight: 500;
margin: 0 0 0.25rem 0;
}
.song-info p {
color: var(--text-secondary);
margin: 0;
}
.song-controls {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
/* Players List */
.players-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 20rem;
overflow-y: scroll;
}
.player-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background-color: var(--bg-tertiary);
border-radius: 0.5rem;
}
.player-status {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
flex-shrink: 0;
}
.player-avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
object-fit: cover;
}
.player-info {
flex: 1;
}
.player-info h4 {
font-size: 1rem;
font-weight: 500;
margin: 0 0 0.25rem 0;
}
.player-status-text {
color: var(--text-secondary);
font-size: 0.875rem;
margin: 0;
}
.kick-button {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background-color: var(--danger-color);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.kick-button:hover {
background-color: var(--danger-hover);
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5);
}
.modify-button {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background-color: var(--accent-color);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.modify-button:hover {
background-color: var(--accent-hover);
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-secondary);
gap: 0.75rem;
}
.empty-state .material-icons {
font-size: 2.5rem;
opacity: 0.6;
}
/* Loading */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 50vh;
gap: 1rem;
}
.spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid rgba(79, 70, 229, 0.2);
border-radius: 50%;
border-top-color: var(--accent-color);
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Error Container */
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
background-color: rgba(239, 68, 68, 0.1);
border-radius: 0.75rem;
color: var(--danger-color);
gap: 0.75rem;
}
.error-container .material-icons {
font-size: 2.5rem;
}
.retry-button {
padding: 0.5rem 1rem;
background-color: var(--danger-color);
color: white;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
margin-top: 0.75rem;
}
.retry-button:hover {
background-color: var(--danger-hover);
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background-color: var(--bg-secondary);
border-radius: 0.75rem;
max-width: 400px;
width: 90%;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.modal-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 1.5rem 1rem 1.5rem;
}
.modal-header .material-icons.warning {
color: var(--danger-color);
font-size: 1.5rem;
}
.modal-header h3 {
font-size: 1.25rem;
font-weight: 500;
margin: 0;
}
.modal-content {
padding: 0 1.5rem 1rem 1.5rem;
}
.modal-content p {
color: var(--text-secondary);
margin: 0;
}
.modal-actions {
display: flex;
gap: 0.75rem;
padding: 1rem 1.5rem 1.5rem 1.5rem;
justify-content: flex-end;
}
.modal-button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.modal-button.cancel {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.modal-button.cancel:hover {
background-color: var(--bg-primary);
}
.modal-button.confirm {
background-color: var(--danger-color);
color: white;
}
.modal-button.confirm:hover {
background-color: var(--danger-hover);
}
.warning-box {
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1.5rem;
display: flex;
gap: 1rem;
align-items: flex-start;
background-color: rgba(245, 158, 11, 0.1);
border-left: 4px solid #f59e0b;
}
/* Material Icons */
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
/* Responsive Design */
@media (max-width: 768px) {
.match-content {
grid-template-columns: 1fr;
}
.players-section {
grid-column: 1;
}
.queue-section {
grid-column: 1;
}
.song-controls {
flex-direction: column;
}
.current-song {
flex-direction: column;
text-align: center;
}
.player-card {
flex-direction: column;
text-align: center;
gap: 0.75rem;
}
}
</style>