some matches but also a lot of pools

This commit is contained in:
Luna 2025-06-12 23:32:07 +02:00
parent 1b14a45826
commit e9d0305566
10 changed files with 2166 additions and 235 deletions

8
package-lock.json generated
View file

@ -16,7 +16,7 @@
"d3": "^7.9.0", "d3": "^7.9.0",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"marked": "^14.1.4", "marked": "^14.1.4",
"moons-ta-client": "^1.1.13", "moons-ta-client": "^1.1.14",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },
@ -3179,9 +3179,9 @@
} }
}, },
"node_modules/moons-ta-client": { "node_modules/moons-ta-client": {
"version": "1.1.13", "version": "1.1.14",
"resolved": "https://registry.npmjs.org/moons-ta-client/-/moons-ta-client-1.1.13.tgz", "resolved": "https://registry.npmjs.org/moons-ta-client/-/moons-ta-client-1.1.14.tgz",
"integrity": "sha512-aoCb5bEZOVpRWl0FDam62ZDojCiGBr9GfGbwPMjJmiY0C6dOpkXT4HGSlFRbYjS5KAjuUy6FCLBMxovuLLS6Zw==", "integrity": "sha512-vHN0/j9qMj8GVgXaxkA/BXsCK5BybpqZ1xtl3Sz2a5zzjIOOUzKuXMRy4+/kpFnSn6R8QgkMmmNU+EorPRH9Eg==",
"dependencies": { "dependencies": {
"@protobuf-ts/plugin": "^2.6.0", "@protobuf-ts/plugin": "^2.6.0",
"events": "^3.3.0", "events": "^3.3.0",

View file

@ -20,7 +20,7 @@
"d3": "^7.9.0", "d3": "^7.9.0",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"marked": "^14.1.4", "marked": "^14.1.4",
"moons-ta-client": "^1.1.13", "moons-ta-client": "^1.1.14",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,230 @@
interface BeatSaverMap {
id: string;
name: string;
description: string;
uploader: {
id: number;
name: string;
hash: string;
avatar: string;
};
metadata: {
bpm: number;
duration: number;
songName: string;
songSubName: string;
songAuthorName: string;
levelAuthorName: string;
};
stats: {
plays: number;
downloads: number;
upvotes: number;
downvotes: number;
score: number;
};
uploaded: string;
automapper: boolean;
ranked: boolean;
qualified: boolean;
versions: Array<{
hash: string;
key: string;
state: string;
createdAt: string;
sageScore: number;
diffs: Array<{
njs: number;
offset: number;
notes: number;
bombs: number;
obstacles: number;
nps: number;
length: number;
characteristic: string;
difficulty: string;
events: number;
chroma: boolean;
me: boolean;
ne: boolean;
cinema: boolean;
seconds: number;
paritySummary: {
errors: number;
warns: number;
resets: number;
};
stars: number;
}>;
downloadURL: string;
previewURL: string;
}>;
}
const BEATSAVER_API_BASE = 'https://api.beatsaver.com';
const BATCH_SIZE = 50;
/**
* Fetches multiple maps from BeatSaver by their hashes or keys
* Automatically batches requests into groups of 50 as per BeatSaver API limits
* Separates keys and hashes to use appropriate endpoints
*/
export async function fetchMapsByHashesOrKeys(identifiers: string[]): Promise<BeatSaverMap[]> {
if (identifiers.length === 0) {
return [];
}
// Separate keys and hashes
const keys: string[] = [];
const hashes: string[] = [];
for (const identifier of identifiers) {
if (isKey(identifier)) {
keys.push(identifier);
} else if (isHash(identifier)) {
hashes.push(identifier);
} else {
console.warn(`Skipping invalid identifier: ${identifier}`);
}
}
const allMaps: BeatSaverMap[] = [];
// Process keys in batches
if (keys.length > 0) {
const keyBatches: string[][] = [];
for (let i = 0; i < keys.length; i += BATCH_SIZE) {
keyBatches.push(keys.slice(i, i + BATCH_SIZE));
}
for (const batch of keyBatches) {
try {
const idsParam = batch.join(',');
const response = await fetch(`${BEATSAVER_API_BASE}/maps/ids/${idsParam}`);
if (!response.ok) {
throw new Error(`BeatSaver API error for keys: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const maps = Object.values(data) as BeatSaverMap[];
allMaps.push(...maps);
} catch (error) {
console.error('Error fetching key batch:', error);
throw error;
}
}
}
// Process hashes in batches
if (hashes.length > 0) {
const hashBatches: string[][] = [];
for (let i = 0; i < hashes.length; i += BATCH_SIZE) {
hashBatches.push(hashes.slice(i, i + BATCH_SIZE));
}
for (const batch of hashBatches) {
try {
const hashesParam = batch.join(',');
const response = await fetch(`${BEATSAVER_API_BASE}/maps/hashes/${hashesParam}`);
if (!response.ok) {
throw new Error(`BeatSaver API error for hashes: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const maps = Object.values(data) as BeatSaverMap[];
allMaps.push(...maps);
} catch (error) {
console.error('Error fetching hash batch:', error);
throw error;
}
}
}
return allMaps;
}
/**
* Determines if an identifier is a BeatSaver key (numeric) or hash (hex string)
*/
function isKey(identifier: string): boolean {
return /^\d+$/.test(identifier);
}
/**
* Determines if an identifier is a hash (40-character hex string)
*/
function isHash(identifier: string): boolean {
return /^[a-fA-F0-9]{40}$/.test(identifier);
}
/**
* Fetches a single map by its hash or key
*/
export async function fetchMapByHashOrKey(identifier: string): Promise<BeatSaverMap | null> {
try {
let endpoint: string;
if (isKey(identifier)) {
endpoint = `${BEATSAVER_API_BASE}/maps/id/${identifier}`;
} else if (isHash(identifier)) {
endpoint = `${BEATSAVER_API_BASE}/maps/hash/${identifier}`;
} else {
throw new Error(`Invalid identifier format. Expected either a numeric key or 40-character hex hash, got: ${identifier}`);
}
const response = await fetch(endpoint);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`BeatSaver API error: ${response.status} ${response.statusText}`);
}
return await response.json() as BeatSaverMap;
} catch (error) {
console.error('Error fetching map:', error);
throw error;
}
}
/**
* Extracts the hash from a Beat Saber level ID string (format: custom_level_levelhash)
*/
export function extractHashFromLevelId(levelId: string): string {
const prefix = 'custom_level_';
if (!levelId.startsWith(prefix)) {
throw new Error(`Invalid level ID format. Expected format: ${prefix}[hash]`);
}
return levelId.substring(prefix.length);
}
/**
* Fetches a map by Beat Saber level ID string (custom_level_levelhash format)
*/
export async function fetchMapByLevelId(levelId: string): Promise<BeatSaverMap | null> {
try {
const hash = extractHashFromLevelId(levelId);
return await fetchMapByHashOrKey(hash);
} catch (error) {
console.error('Error fetching map by level ID:', error);
throw error;
}
}
/**
* Fetches multiple maps by Beat Saber level ID strings
* Automatically extracts hashes and batches requests
*/
export async function fetchMapsByLevelIds(levelIds: string[]): Promise<BeatSaverMap[]> {
try {
const hashes = levelIds.map(levelId => extractHashFromLevelId(levelId));
return await fetchMapsByHashesOrKeys(hashes);
} catch (error) {
console.error('Error fetching maps by level IDs:', error);
throw error;
}
}

24
src/lib/taDocs/taInfo.md Normal file
View file

@ -0,0 +1,24 @@
# Further information about the TournamentAssistant Client
- As stated many times previously, TournamentAssistant uses ProtoBuf. ProtoBuf is a data serialiser which allows TA to transport smaller packets. This is essential since all of the live scores are sent to all of the clients who subscribe to it etc.
- The connection to the TA Core Server is a websocket connection. If you wish, you can opt not to use the provided client, however it is strongly advised against due to serialisation.
- Beware that the coordinator(or the client in other words) connects to a different port on the server than the player playing, hence it is not possible to directly listen to packets from players.
- TournamentAssistant does not *yet* support hosting your own Core Server in order to reduce complications for players and developers alike.
## StateManager
TournamentAssistant is a state-based application. This means that when you join a tournament you are not "fetching" the tournament info, rather you are pulling it from the state. It is often much more efficient to use taClient.stateManager rather then waiting for a response from the server.
When you initiate the websocket connection, the initial state of the core server is passed on. This is sanitised data and the tournament which should be hidden will not be passed on here. Once this data is deserialised, it is stored and updated in the stateManager.
You might also notice that the stateManager is the only way to fetch tournament info etc. This is again done to ease development and redice server load.
<div class="info-box">
<span class="material-icons">info</span>
<div>
<strong>Will the stateManager be out-of-date?</strong> The stateManager automatically updates its state when a packet is received or sent, so no, you do not have to worry about out-of-date information being stored.
</div>
</div>

View file

@ -19,7 +19,8 @@
items: [ items: [
{ title: "Introduction", file: "intro", order: 1 }, { title: "Introduction", file: "intro", order: 1 },
{ title: "Installation", file: "sample", order: 2 }, { title: "Installation", file: "sample", order: 2 },
{ title: "Client", file: "client", order: 3 } { title: "Client", file: "client", order: 3 },
{ title: "How TA Works, Info", file: "taInfo", order: 4 }
] ]
}, },
{ {

View file

@ -6,6 +6,7 @@
import { Match, Tournament, TAClient, Response_ResponseType } from 'moons-ta-client'; import { Match, Tournament, TAClient, Response_ResponseType } from 'moons-ta-client';
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import SideMenu from "$lib/components/menus/SideMenuTournaments.svelte"; import SideMenu from "$lib/components/menus/SideMenuTournaments.svelte";
import { goto } from "$app/navigation";
export let data; export let data;
@ -16,6 +17,7 @@
let matches: Match[] = []; let matches: Match[] = [];
let availablePlayers: any[] = []; let availablePlayers: any[] = [];
let selectedPlayers: any[] = []; let selectedPlayers: any[] = [];
let isCreatingMatch: boolean = false;
// Store for selected player guids // Store for selected player guids
const selectedPlayerGuids = writable<string[]>([]); const selectedPlayerGuids = writable<string[]>([]);
@ -63,7 +65,7 @@
// Handle match status changes // Handle match status changes
function handleMatchUpdated(params: any) { function handleMatchUpdated(params: any) {
console.log("matchUpdate", params); console.log("matchUpdate", params);
if (params.guid === tournamentGuid) { if (params[1].guid === tournamentGuid) {
// Update matches list // Update matches list
fetchTournamentData(); fetchTournamentData();
} }
@ -71,6 +73,16 @@
// Handle match deletion // Handle match deletion
function handleMatchDeleted(matchInfo: any) { function handleMatchDeleted(matchInfo: any) {
console.log('matchDeleted', matchInfo)
if (matchInfo[1].guid === tournamentGuid) {
// Update matches list
fetchTournamentData();
}
};
// Handle match deletion
function handleMatchCreated(matchInfo: any) {
console.log('matchCreated', matchInfo);
if (matchInfo[1].guid === tournamentGuid) { if (matchInfo[1].guid === tournamentGuid) {
// Update matches list // Update matches list
fetchTournamentData(); fetchTournamentData();
@ -105,10 +117,6 @@
tournament = joinResult.details.join.state?.tournaments.find((x: Tournament) => x.guid === tournamentGuid); tournament = joinResult.details.join.state?.tournaments.find((x: Tournament) => x.guid === tournamentGuid);
} }
if(client.stateManager.getTournaments().some(tournament => tournament.users.some(user => user.guid == client.stateManager.getSelfGuid() && user.clientType == 1))) {
}
// Wait for state to be fully initialized // Wait for state to be fully initialized
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
@ -132,14 +140,8 @@
availablePlayers = allUsers.filter(user => !matches.some(match => match.associatedUsers.includes(user.guid))); availablePlayers = allUsers.filter(user => !matches.some(match => match.associatedUsers.includes(user.guid)));
client.on('createdMatch', (params) => { client.on('createdMatch', (params) => {
console.log("paramsUpdate", params) console.log("paramsUpdate", params);
}); });
client.on('updatedMatch', (params) => {
console.log("paramsUpdate", params)
})
client.on('deletedMatch', handleMatchDeleted);
} catch (err) { } catch (err) {
console.error('Error fetching tournament data:', err); console.error('Error fetching tournament data:', err);
error = err instanceof Error ? err.message : 'An unknown error occurred'; error = err instanceof Error ? err.message : 'An unknown error occurred';
@ -158,11 +160,18 @@
}); });
console.log(response) console.log(response)
const newMatch = (response as any).details.createMatch.match;
goto(`/tournaments/${tournamentGuid}/matches/${newMatch.guid}`);
} }
onMount(async() => { onMount(async() => {
if ($authTokenStore) { if ($authTokenStore) {
await fetchTournamentData(); await fetchTournamentData();
client.stateManager.on('matchCreated', handleMatchCreated);
client.stateManager.on('matchDeleted', handleMatchDeleted);
client.stateManager.on('matchUpdated', handleMatchUpdated);
} else { } else {
window.location.href = "/discordAuth" window.location.href = "/discordAuth"
} }
@ -171,6 +180,7 @@
onDestroy(() => { onDestroy(() => {
client.stateManager.removeListener('matchUpdated', handleMatchUpdated); client.stateManager.removeListener('matchUpdated', handleMatchUpdated);
client.stateManager.removeListener('matchDeleted', handleMatchDeleted); client.stateManager.removeListener('matchDeleted', handleMatchDeleted);
client.stateManager.removeListener('matchCreated', handleMatchDeleted);
client.removeListener('createdMatch', handleMatchUpdated) client.removeListener('createdMatch', handleMatchUpdated)
// client.disconnect(); // client.disconnect();
}); });
@ -242,7 +252,7 @@
{/if} {/if}
</div> </div>
<div class="match-actions"> <div class="match-actions">
<a href={`/casting/${tournamentGuid}/${match.guid}`} class="match-action-button"> <a href={`/tournaments/${tournamentGuid}/matches/${match.guid}`} class="match-action-button">
<span class="material-icons">visibility</span> <span class="material-icons">visibility</span>
View View
</a> </a>

View file

@ -66,31 +66,32 @@
enabled: boolean; enabled: boolean;
disabled?: boolean; disabled?: boolean;
group?: string; group?: string;
incompatibilites?: GameplayModifiers_GameOptions[]; incompatibilityGroup?: string;
allowedInMode: string[];
} }
let gameModifiers: Modifier[] = [ const modifierNameMap: Record<number, string> = {
// Player modifiers [GameplayModifiers_GameOptions.None]: "",
{ id: GameplayModifiers_GameOptions.NoFail, name: "No Fail", description: "You can't fail the level", enabled: false, group: "Player", incompatibilites: [GameplayModifiers_GameOptions.InstaFail, GameplayModifiers_GameOptions.BatteryEnergy] }, [GameplayModifiers_GameOptions.NoFail]: "No Fail",
{ id: GameplayModifiers_GameOptions.InstaFail, name: "One Life", description: "You only have one life", enabled: false, group: "Player", incompatibilites: [GameplayModifiers_GameOptions.NoFail, GameplayModifiers_GameOptions.BatteryEnergy] }, [GameplayModifiers_GameOptions.NoBombs]: "No Bombs",
{ id: GameplayModifiers_GameOptions.BatteryEnergy, name: "Four Lives", description: "You have four lives", enabled: false, group: "Player", incompatibilites: [GameplayModifiers_GameOptions.NoFail, GameplayModifiers_GameOptions.InstaFail] }, [GameplayModifiers_GameOptions.NoArrows]: "No Arrows",
{ id: GameplayModifiers_GameOptions.NoBombs, name: "No Bombs", description: "No bombs will appear", enabled: false, group: "Player" }, [GameplayModifiers_GameOptions.NoObstacles]: "No Walls",
{ id: GameplayModifiers_GameOptions.NoObstacles, name: "No Walls", description: "No walls will appear", enabled: false, group: "Player" }, [GameplayModifiers_GameOptions.SlowSong]: "Slower Song",
{ id: GameplayModifiers_GameOptions.NoArrows, name: "No Arrows", description: "All notes can be cut in any direction", enabled: false, group: "Player", incompatibilites: [GameplayModifiers_GameOptions.DisappearingArrows] }, [GameplayModifiers_GameOptions.InstaFail]: "One Life",
{ id: GameplayModifiers_GameOptions.GhostNotes, name: "Ghost Notes", description: "Note colors are hidden", enabled: false, group: "Player" }, [GameplayModifiers_GameOptions.FailOnClash]: "Fail on Clash",
{ id: GameplayModifiers_GameOptions.DisappearingArrows, name: "Disappearing Arrows", description: "Arrows disappear as they approach you", enabled: false, group: "Player", incompatibilites: [GameplayModifiers_GameOptions.NoArrows] }, [GameplayModifiers_GameOptions.BatteryEnergy]: "Four Lives",
[GameplayModifiers_GameOptions.FastNotes]: "Fast Notes",
// Speed modifiers [GameplayModifiers_GameOptions.FastSong]: "Faster Song",
{ id: GameplayModifiers_GameOptions.SlowSong, name: "Slower Song", description: "Reduces the song speed by 15%", enabled: false, group: "Speed", incompatibilites: [GameplayModifiers_GameOptions.FastSong, GameplayModifiers_GameOptions.SuperFastSong] }, [GameplayModifiers_GameOptions.DisappearingArrows]: "Disappearing Arrows",
{ id: GameplayModifiers_GameOptions.FastSong, name: "Faster Song", description: "Increases the song speed by 20%", enabled: false, group: "Speed", incompatibilites: [GameplayModifiers_GameOptions.SlowSong, GameplayModifiers_GameOptions.SuperFastSong] }, [GameplayModifiers_GameOptions.GhostNotes]: "Ghost Notes",
{ id: GameplayModifiers_GameOptions.SuperFastSong, name: "Super Fast Song", description: "Increases the song speed by 50%", enabled: false, group: "Speed", incompatibilites: [GameplayModifiers_GameOptions.SlowSong, GameplayModifiers_GameOptions.FastSong] }, [GameplayModifiers_GameOptions.DemoNoFail]: "Demo No Fail",
[GameplayModifiers_GameOptions.DemoNoObstacles]: "Demo No Obstacles",
// Environment modifiers [GameplayModifiers_GameOptions.StrictAngles]: "Strict Angles",
{ id: GameplayModifiers_GameOptions.SmallCubes, name: "Small Notes", description: "Notes are smaller", enabled: false, group: "Environment", incompatibilites: [GameplayModifiers_GameOptions.ProMode] }, [GameplayModifiers_GameOptions.ProMode]: "Pro Mode",
{ id: GameplayModifiers_GameOptions.ProMode, name: "Pro Mode", description: "Makes notes smaller, removes debris, and adds hit scores", enabled: false, group: "Environment", incompatibilites: [GameplayModifiers_GameOptions.SmallCubes] }, [GameplayModifiers_GameOptions.ZenMode]: "Zen Mode",
{ id: GameplayModifiers_GameOptions.ZenMode, name: "Zen Mode", description: "No fail, no bombs, reduced obstacles", enabled: false, group: "Environment" }, [GameplayModifiers_GameOptions.SmallCubes]: "Small Notes",
{ id: GameplayModifiers_GameOptions.StrictAngles, name: "Strict Angles", description: "Stricter angle enforcement for cuts", enabled: false, group: "Environment" } [GameplayModifiers_GameOptions.SuperFastSong]: "Super Fast Song"
]; };
// Edit Map Modal state // Edit Map Modal state
let showEditMapModal: boolean = false; let showEditMapModal: boolean = false;
@ -147,7 +148,9 @@
showSuccessNotification = false; showSuccessNotification = false;
} }
function handleMapUpdated(event: CustomEvent) { async function handleMapUpdated(event: CustomEvent) {
const res = await client.updateTournamentPoolMap(tournamentGuid, poolGuid, event.detail.map);
console.log(res)
fetchMapPoolData(); fetchMapPoolData();
showSuccessNotification = true; showSuccessNotification = true;
successMessage = "Map successfully updated!"; successMessage = "Map successfully updated!";
@ -215,6 +218,7 @@
tournament = client.stateManager.getTournament(tournamentGuid)!; tournament = client.stateManager.getTournament(tournamentGuid)!;
client.stateManager.on('tournamentUpdated', (data) => console.log("updateGlobal", data));
// Find the map pool with the specified GUID // Find the map pool with the specified GUID
const pool = await tournament?.settings?.pools.find(p => p.guid === poolGuid); const pool = await tournament?.settings?.pools.find(p => p.guid === poolGuid);
@ -224,8 +228,9 @@
} }
poolName = pool.name; poolName = pool.name;
maps = [];
await pool.maps.map(async(map) => { await pool.maps.map(async(map) => {
console.log(map); console.log("mapData that was newly fetched", map);
let beatsaverData = await getMapBeatsaverData(map.gameplayParameters!.beatmap!.levelId); let beatsaverData = await getMapBeatsaverData(map.gameplayParameters!.beatmap!.levelId);
maps = [...maps, { maps = [...maps, {
taData: map, taData: map,
@ -233,6 +238,9 @@
}]; }];
}); });
client.on('failedToUpdateTournament', (data) => {console.log("tourneyUpdate", data)})
client.stateManager.on('tournamentUpdated', (data) => {console.log("tourneyUpdateSM", data)});
maps = maps; maps = maps;
} catch (err) { } catch (err) {
console.error('Error fetching map pool data:', err); console.error('Error fetching map pool data:', err);
@ -256,6 +264,39 @@
return response; return response;
} }
function getActiveModifiersCompact(gameplayModifiers: number): string[] {
const activeModifiers: string[] = [];
// Get all numeric enum values and sort them
const values = Object.values(GameplayModifiers_GameOptions)
.filter((value): value is number =>
typeof value === 'number' &&
value !== GameplayModifiers_GameOptions.None
)
.sort((a, b) => a - b);
console.log(values)
for (const value of values) {
if (gameplayModifiers & value) {
const name = modifierNameMap[value];
if (name && name.trim() !== "") {
activeModifiers.push(name);
}
}
}
return activeModifiers;
}
function getModifierArray(map: any): string[] {
if (map.taData.gameplayParameters?.gameplayModifiers.options == 0) {
return [];
}
return getActiveModifiersCompact(map.taData.gameplayParameters.gameplayModifiers);
}
onMount(async() => { onMount(async() => {
if ($authTokenStore) { if ($authTokenStore) {
await fetchMapPoolData(); await fetchMapPoolData();
@ -340,12 +381,12 @@
{MapDifficulty[map.taData.gameplayParameters?.beatmap?.difficulty]} {MapDifficulty[map.taData.gameplayParameters?.beatmap?.difficulty]}
</span> </span>
{/if} {/if}
<!-- {#if map.length} {#if map.beatsaverData.metadata.duration}
<span class="map-length"> <span class="map-length">
<span class="material-icons">access_time</span> <span class="material-icons">access_time</span>
{map.length} {Math.floor(map.beatsaverData.metadata.duration / 60)} minutes {map.beatsaverData.metadata.duration - Math.floor(map.beatsaverData.metadata.duration / 60) * 60} seconds
</span> </span>
{/if} --> {/if}
</div> </div>
</div> </div>
<div class="map-actions"> <div class="map-actions">
@ -368,13 +409,11 @@
<div class="map-modifiers"> <div class="map-modifiers">
<h5>Active Modifiers</h5> <h5>Active Modifiers</h5>
<div class="modifier-tags"> <div class="modifier-tags">
<!-- {#if map.modifiers && map.modifiers.length > 0} {#each getModifierArray(map) as modifier}
{#each map.modifiers as modifier}
<span class="modifier-tag">{modifier}</span> <span class="modifier-tag">{modifier}</span>
{/each}
{:else} {:else}
<span class="no-modifiers">No modifiers active</span> <span class="no-modifiers">No modifiers active</span>
{/if} --> {/each}
</div> </div>
</div> </div>
</div> </div>
@ -394,6 +433,7 @@
on:close={closeEditMapModal} on:close={closeEditMapModal}
on:mapUpdated={handleMapUpdated} on:mapUpdated={handleMapUpdated}
on:mapAdded={handleMapAdded} on:mapAdded={handleMapAdded}
mode='playing'
/> />
{/if} {/if}
@ -709,16 +749,27 @@
.modifier-tag { .modifier-tag {
background-color: var(--bg-tertiary); background-color: var(--bg-tertiary);
padding: 0.25rem 0.5rem; padding: 0.25rem 0.75rem;
border-radius: 1rem; border-radius: 1rem;
font-size: 0.75rem; font-size: 0.8rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.modifier-tag::before {
content: "•";
color: var(--accent-color);
font-weight: bold;
} }
.no-modifiers { .no-modifiers {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
font-style: italic; font-style: italic;
} }
/* No maps state */ /* No maps state */
.no-maps { .no-maps {

View file

@ -0,0 +1,967 @@
<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 } from 'moons-ta-client';
import { writable } from "svelte/store";
import { goto } from "$app/navigation";
import { fetchMapByLevelId } from "$lib/services/beatsaver.js";
export let data;
const tournamentGuid: string = data.tournamentGuid;
const matchGuid: string = data.matchGuid;
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: any = null;
let isMatchActive = false;
let streamSyncEnabled = false;
// Modal states
let showKickConfirmModal = false;
let showBackToMenuModal = false;
let showExitMatchModal = false;
let playerToKick: User | null = null;
let showCannotExitPage: boolean = false;
// Player status enum
enum PlayerStatus {
Downloading = "downloading",
SelectingSong = "selecting",
Idle = "idle"
}
enum PlayerPlayState {
In_Menu = 0,
Waiting_For_Coordinator = 1,
In_Game = 2
}
// Remove 'Bearer ' from the token
if($authTokenStore) {
const cleanToken = $authTokenStore.replace('Bearer ', '');
client.setAuthToken(cleanToken);
}
// Check if any player is currently playing
function isAnyPlayerPlaying(): boolean {
return matchPlayers.some(player => (player.playState as any) === PlayerPlayState.In_Game);
}
// Show notification function (you mentioned you have this)
function showNotification(message: string, type: 'warning' | 'error' | 'success' = 'warning') {
// Your existing notification function
console.log(`${type}: ${message}`);
}
// Handle back to matches with confirmation if needed
async function handleBackToMatches() {
if (isAnyPlayerPlaying()) {
showExitMatchModal = true;
} else {
// await client.deleteMatch(tournamentGuid, matchGuid);
goto(`/tournaments/${tournamentGuid}`);
}
}
// Handle back to matches with confirmation if needed
async function handleEndMatch() {
await client.deleteMatch(tournamentGuid, matchGuid);
goto(`/tournaments/${tournamentGuid}`);
}
// 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;
showKickConfirmModal = true;
}
// Confirm kick player
async function confirmKickPlayer() {
if (!playerToKick) 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();
showNotification('Song started for all players', 'success');
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;
}
// 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: any): string {
switch (playState) {
case PlayerPlayState.In_Game:
return "var(--danger-color)";
case PlayerPlayState.Waiting_For_Coordinator:
return "#FCD34D"; // Yellow
case PlayerPlayState.In_Menu:
return "#10B981"; // Green
default:
return "var(--text-secondary)";
}
}
// Get status text
function getStatusText(playState: any): string {
return PlayerPlayState[playState].replace('_', ' ');
}
// Handle match events
function handleMatchUpdated(params: any) {
console.log("Match updated:", params);
if (params.details.updateMatch.match.guid === matchGuid) {
fetchMatchData();
}
}
function handleMatchDeleted(params: any) {
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 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);
console.log("match", match)
if (!match) {
throw new Error('Match not found');
}
// 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?.beatmap || null;
if(currentSong) {
currentSongData = await fetchMapByLevelId(currentSong.levelId);
}
} catch (err) {
console.error('Error fetching match data:', err);
error = err instanceof Error ? err.message : 'An unknown error occurred';
} finally {
isLoading = false;
}
}
// API functions - implement these based on your TA client
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() {
// const response = await client.playSong()
console.log('Starting song for match');
}
async function startSongWithStreamSyncForMatch() {
// Implement start song with stream sync logic
console.log('Starting song with stream sync');
}
async function sendPlayersBackToMenu() {
// Implement back to menu logic
console.log('Sending players back to menu');
}
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);
} else {
window.location.href = "/discordAuth"
}
});
onDestroy(() => {
// Remove the listeners that were added on mount
client.stateManager.removeListener('matchUpdated', handleMatchUpdated);
client.stateManager.removeListener('matchDeleted', handleMatchDeleted);
});
</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}
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}>
<span class="material-icons">person_remove</span>
End Match
</button>
<div class="match-info">
<h2>Match: {'Unknown'}</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.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}
<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 isMatchActive}
<button class="action-button back-to-menu" on:click={handleBackToMenu}>
<span class="material-icons">home</span>
Back to Menu
</button>
{/if}
</div>
</div>
<!-- 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)}"></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">{getStatusText(player.playState)}</p>
</div>
<button class="kick-button" on:click={() => handleKickPlayer(player)}>
<span class="material-icons">person_remove</span>
</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Future: Song Queue Section -->
<div class="queue-section">
<h3>Song Queue</h3>
<div class="empty-state">
<span class="material-icons">queue_music</span>
<p>Queue feature coming soon</p>
</div>
</div>
</div>
{/if}
</div>
</main>
</div>
<!-- Kick Player Confirmation Modal -->
{#if showKickConfirmModal}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal-overlay" on:click={() => showKickConfirmModal = false}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal" on:click|stopPropagation>
<div class="modal-header">
<span class="material-icons warning">warning</span>
<h3>Kick Player</h3>
</div>
<div class="modal-content">
<p>Are you sure you want to kick <strong>{playerToKick?.name}</strong> from the match?</p>
</div>
<div class="modal-actions">
<button class="modal-button cancel" on:click={() => showKickConfirmModal = false}>Cancel</button>
<button class="modal-button confirm" on:click={confirmKickPlayer}>Kick Player</button>
</div>
</div>
</div>
{/if}
<!-- Back to Menu Confirmation Modal -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if showBackToMenuModal}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal-overlay" on:click={() => showBackToMenuModal = false}>
<div class="modal" on:click|stopPropagation>
<div class="modal-header">
<span class="material-icons warning">warning</span>
<h3>Back to Menu</h3>
</div>
<div class="modal-content">
<p>This will send all players back to the menu and stop the current song. Continue?</p>
</div>
<div class="modal-actions">
<button class="modal-button cancel" on:click={() => showBackToMenuModal = false}>Cancel</button>
<button class="modal-button confirm" on:click={confirmBackToMenu}>Continue</button>
</div>
</div>
</div>
{/if}
<!-- Exit Match Confirmation Modal -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if showExitMatchModal}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="modal-overlay" on:click={() => showExitMatchModal = false}>
<div class="modal" on:click|stopPropagation>
<div class="modal-header">
<span class="material-icons warning">warning</span>
<h3>Exit Match</h3>
</div>
<div class="modal-content">
<p>Players are currently in-game. Exiting will close the match for all players. Continue?</p>
</div>
<div class="modal-actions">
<button class="modal-button cancel" on:click={() => showExitMatchModal = false}>Cancel</button>
<button class="modal-button confirm" on:click={confirmExitMatch}>Exit Match</button>
</div>
</div>
</div>
{/if}
<style>
/* Global styles inherited from main stylesheet */
.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;
}
.players-section {
grid-column: 1;
}
.queue-section {
grid-column: 2;
}
/* Section Styles */
.song-section, .players-section, .queue-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: 50vh;
overflow-y: auto;
}
.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);
}
/* 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);
}
/* 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>

View file

@ -0,0 +1,7 @@
export function load({ params }: any){
return {
tournamentGuid: params.tournamentguid,
matchGuid: params.matchGuid,
params: params
}
}