we do be staging

This commit is contained in:
Luna 2025-06-21 18:53:06 +02:00
parent 49e1da1cbd
commit 3da3624df0
11 changed files with 3036 additions and 326 deletions

View file

@ -2,42 +2,12 @@
import { createEventDispatcher } from 'svelte';
import { type Map, GameplayModifiers_GameOptions, Push_SongFinished, RealtimeScore } from 'moons-ta-client';
import type { BeatSaverMap } from '$lib/services/beatsaver.js';
interface ScoreWithAccuracy extends Push_SongFinished {
accuracy: number;
}
interface PreviousResults {
taData: Map;
beatsaverData: BeatSaverMap;
scores: ScoreWithAccuracy[];
completionType: 'Completed' | 'Still Awaiting Scores' | 'Exited To Menu';
}
import { type ScoreWithAccuracy, type PreviousResults, MapDifficulty } from '$lib/interfaces/match.interfaces';
export let maps: PreviousResults[] = [];
$: maps = maps.map(map => {
const newScores = map.scores.map(score => {
const accuracy = calculateAccuracy(score, map);
return {
...score,
accuracy: parseFloat(accuracy)
} as ScoreWithAccuracy;
});
map.scores = newScores;
return map;
});
const dispatch = createEventDispatcher();
enum MapDifficulty {
"Easy" = 0,
"Normal" = 1,
"Hard" = 2,
"Expert" = 3,
"ExpertPlus" = 4,
}
const modifierNameMap: Record<number, string> = {
[GameplayModifiers_GameOptions.None]: "",
[GameplayModifiers_GameOptions.NoFail]: "No Fail",
@ -87,7 +57,7 @@
discordMessage += `**${positionText} ${displayName}**\n`;
discordMessage += `Score: ${score.score.toLocaleString()}\n`;
discordMessage += `Accuracy: ${score.accuracy.toFixed(2)}%\n`;
discordMessage += `Accuracy: ${score.accuracy}%\n`;
discordMessage += `Misses: ${score.misses} | Bad Cuts: ${score.badCuts}\n\n`;
});
@ -162,32 +132,6 @@
return 'help';
}
}
function getMaxScore(songInfo: BeatSaverMap, characteristic: string = "standard", difficulty: string): number {
const diff = maps.find(x => x.beatsaverData.versions[0].hash == songInfo.versions[0].hash)?.beatsaverData.versions[0].diffs.find(
(x) => ((characteristic.toLowerCase() !== 'expertplus' && characteristic.toLowerCase() !== 'expert+') ? x.characteristic.toLowerCase() === characteristic.toLowerCase() : (x.characteristic.toLowerCase() == 'expertplus' || x.characteristic.toLowerCase() == 'expert+')) && x.difficulty.toLowerCase() === difficulty.toLowerCase()
);
return diff?.maxScore ?? 0;
}
// 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: PreviousResults): string {
console.log(maps)
const maxScore = getMaxScore(
mapWithSongInfo.beatsaverData,
score.beatmap?.characteristic?.serializedName ?? "Standard",
getDifficultyAsString(score.beatmap?.difficulty ?? 4) || "ExpertPlus"
);
const accuracy = ((score.score / maxScore) * 100).toFixed(2);
return accuracy;
}
</script>
<div class="previously-played">

View file

@ -58,6 +58,10 @@
dispatch('removeMap', { map: map });
}
function handleAddMapsFromTAPool() {
dispatch('addMapsFromTAPool');
}
function handleLoadMap(map: CustomMap) {
mapToLoad = map;
showConfirmDialog = true;
@ -121,6 +125,10 @@
<span class="material-icons">add</span>
Add Map
</button>
<button class="action-button add-map" on:click={handleAddMapsFromTAPool}>
<span class="material-icons">add</span>
Add Maps From TA Pool
</button>
</div>
{#if maps.length === 0}
@ -337,8 +345,8 @@
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 60vh;
overflow-y: auto;
/* max-height: 60vh;
overflow-y: auto; */
}
.map-card {
@ -608,7 +616,7 @@
-webkit-font-smoothing: antialiased;
}
/* Scrollbar Styling */
/* Scrollbar Styling
.maps-list::-webkit-scrollbar {
width: 0.375rem;
}
@ -625,7 +633,7 @@
.maps-list::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
} */
/* Responsive Design */
@media (max-width: 768px) {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,657 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { CustomTAMapPool, CustomMap } from '$lib/interfaces/match.interfaces';
import { bufferToImageUrl } from '$lib/services/taImages';
export let isOpen = false;
export let mapPools: CustomTAMapPool[] = [];
const dispatch = createEventDispatcher();
// Track which pools are expanded
let expandedPools: Set<string> = new Set();
// Track selected maps by their guid
let selectedMaps: Set<string> = new Set();
// Track if entire pools are selected
let selectedPools: Set<string> = new Set();
function togglePoolExpansion(poolGuid: string) {
const newExpanded = new Set(expandedPools);
if (newExpanded.has(poolGuid)) {
newExpanded.delete(poolGuid);
} else {
newExpanded.add(poolGuid);
}
expandedPools = newExpanded;
}
function togglePoolSelection(pool: CustomTAMapPool) {
const newSelectedPools = new Set(selectedPools);
const newSelectedMaps = new Set(selectedMaps);
if (selectedPools.has(pool.guid)) {
// Unselect pool and all its maps
newSelectedPools.delete(pool.guid);
pool.maps.forEach(map => {
newSelectedMaps.delete(map.taData.guid);
});
} else {
// Select pool and all its maps
newSelectedPools.add(pool.guid);
pool.maps.forEach(map => {
newSelectedMaps.add(map.taData.guid);
});
}
selectedPools = newSelectedPools;
selectedMaps = newSelectedMaps;
}
function toggleMapSelection(map: CustomMap, poolGuid: string) {
const newSelectedMaps = new Set(selectedMaps);
const newSelectedPools = new Set(selectedPools);
if (selectedMaps.has(map.taData.guid)) {
newSelectedMaps.delete(map.taData.guid);
// If pool was fully selected, unselect it
newSelectedPools.delete(poolGuid);
} else {
newSelectedMaps.add(map.taData.guid);
// Check if all maps in this pool are now selected
const pool = mapPools.find(p => p.guid === poolGuid);
if (pool && pool.maps.every(m => newSelectedMaps.has(m.taData.guid))) {
newSelectedPools.add(poolGuid);
}
}
selectedMaps = newSelectedMaps;
selectedPools = newSelectedPools;
}
function getDifficultyName(difficulty: number): string {
const difficultyMap: { [key: number]: string } = {
0: "Easy",
1: "Normal",
2: "Hard",
3: "Expert",
4: "Expert+"
};
return difficultyMap[difficulty] || "Unknown";
}
function getModifiersText(modifiersBitmap: number): string {
// This would need your actual modifiers mapping
// For now, just show if modifiers are present
return modifiersBitmap > 0 ? "Modifiers Active" : "No Modifiers";
}
function calculateDuration(map: CustomMap): string {
// You mentioned you have a function for this - placeholder implementation
if (map.beatsaverData?.metadata?.duration) {
const duration = map.beatsaverData.metadata.duration;
const minutes = Math.floor(duration / 60);
const seconds = duration % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
return "0:00";
}
function getCoverUrl(map: CustomMap): string {
return map.beatsaverData?.versions?.[0]?.coverURL || '';
}
function handleClose() {
dispatch('close');
}
function handleAddMaps() {
const mapsToAdd: CustomMap[] = [];
mapPools.forEach(pool => {
pool.maps.forEach(map => {
if (selectedMaps.has(map.taData.guid)) {
mapsToAdd.push(map);
}
});
});
if (mapsToAdd.length > 0) {
dispatch('addMaps', { maps: mapsToAdd });
}
}
$: selectedCount = selectedMaps.size;
</script>
{#if isOpen}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="popup-overlay" on:click={handleClose}>
<div class="popup-container" on:click|stopPropagation>
<div class="popup-header">
<div class="header-content">
<h3>Add Maps from TA Pool</h3>
</div>
<button class="close-button" on:click={handleClose}>
<span class="material-icons">close</span>
</button>
</div>
<div class="popup-content">
<div class="form-sections">
<div class="form-section">
<h4>Map Pools</h4>
<p class="section-desc">Select individual maps or entire pools to add to your match</p>
<div class="pools-list">
{#each mapPools as pool (pool.guid)}
<div class="pool-container">
<div class="pool-header">
<div class="pool-info">
<div class="pool-image">
<img src={bufferToImageUrl(pool.image)} alt={pool.name} />
</div>
<div class="pool-details">
<h5 class="pool-name">{pool.name}</h5>
<span class="pool-count">{pool.maps.length} maps</span>
</div>
</div>
<div class="pool-controls">
<label class="pool-checkbox">
<input
type="checkbox"
checked={selectedPools.has(pool.guid)}
on:change={() => togglePoolSelection(pool)}
/>
<span class="checkmark"></span>
</label>
<button
class="expand-button"
on:click={() => togglePoolExpansion(pool.guid)}
class:expanded={expandedPools.has(pool.guid)}
>
<span class="material-icons">expand_more</span>
</button>
</div>
</div>
{#if expandedPools.has(pool.guid)}
<div class="maps-list">
{#each pool.maps as map (map.taData.guid)}
<div class="map-item">
<div class="map-checkbox">
<label>
<input
type="checkbox"
checked={selectedMaps.has(map.taData.guid)}
on:change={() => toggleMapSelection(map, pool.guid)}
/>
<span class="checkmark"></span>
</label>
</div>
<div class="map-cover">
<img src={getCoverUrl(map)} alt={map.taData.gameplayParameters?.beatmap?.name} />
</div>
<div class="map-info">
<div class="map-title">{map.taData.gameplayParameters?.beatmap?.name}</div>
<div class="map-details">
<span class="map-duration">{calculateDuration(map)}</span>
<span class="map-separator"></span>
<span class="map-difficulty {getDifficultyName(map.taData.gameplayParameters?.beatmap?.difficulty || 4).toLowerCase()}">
{getDifficultyName(map.taData.gameplayParameters?.beatmap?.difficulty || 4)}
</span>
<span class="map-separator"></span>
<span class="map-modifiers">{getModifiersText(map.taData.gameplayParameters?.gameplayModifiers?.options || 0)}</span>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
<div class="form-actions">
<button class="action-button cancel" on:click={handleClose}>
Cancel
</button>
<button
class="action-button save"
on:click={handleAddMaps}
disabled={selectedCount === 0}
>
<span class="material-icons">add</span>
Add {selectedCount} Maps
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.popup-container {
background-color: var(--bg-primary);
border-radius: 0.75rem;
width: 90%;
max-width: 900px;
max-height: 90vh;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--bg-secondary);
}
.header-content h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.close-button {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
width: 2rem;
height: 2rem;
border-radius: 0.25rem;
}
.close-button:hover {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.popup-content {
padding: 1.5rem;
overflow-y: auto;
}
.form-sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
.form-section {
background-color: var(--bg-secondary);
border-radius: 0.5rem;
padding: 1.25rem;
}
.form-section h4 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 600;
}
.section-desc {
color: var(--text-secondary);
font-size: 0.875rem;
margin: -0.5rem 0 1rem 0;
}
.pools-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pool-container {
background-color: var(--bg-primary);
border-radius: 0.5rem;
border: 1px solid var(--bg-tertiary);
overflow: hidden;
}
.pool-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
cursor: pointer;
}
.pool-info {
display: flex;
align-items: center;
gap: 1rem;
}
.pool-image {
width: 3rem;
height: 3rem;
border-radius: 0.375rem;
overflow: hidden;
flex-shrink: 0;
}
.pool-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pool-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.pool-name {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.pool-count {
font-size: 0.875rem;
color: var(--text-secondary);
}
.pool-controls {
display: flex;
align-items: center;
gap: 0.75rem;
}
.expand-button {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
width: 2rem;
height: 2rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.expand-button:hover {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.expand-button.expanded {
transform: rotate(180deg);
}
.maps-list {
border-top: 1px solid var(--bg-tertiary);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 400px;
overflow-y: auto;
}
.map-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background-color: var(--bg-secondary);
border-radius: 0.375rem;
border: 1px solid var(--bg-tertiary);
}
.map-checkbox {
flex-shrink: 0;
}
.map-cover {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.25rem;
overflow: hidden;
flex-shrink: 0;
}
.map-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.map-info {
flex: 1;
min-width: 0;
}
.map-title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.map-details {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--text-secondary);
flex-wrap: wrap;
}
.map-separator {
opacity: 0.5;
}
.map-difficulty {
background-color: var(--accent-color);
color: white;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.map-difficulty.easy {
background-color: #4ade80;
}
.map-difficulty.normal {
background-color: #3b82f6;
}
.map-difficulty.hard {
background-color: #f59e0b;
}
.map-difficulty.expert {
background-color: #ef4444;
}
.map-difficulty.expert\+ {
background-color: #8b5cf6;
}
.map-duration,
.map-modifiers {
font-size: 0.8125rem;
}
/* Checkbox styling */
.pool-checkbox,
.map-checkbox label {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.pool-checkbox input,
.map-checkbox input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.checkmark {
position: relative;
display: inline-block;
width: 1.25rem;
height: 1.25rem;
background-color: var(--bg-tertiary);
border: 2px solid var(--bg-tertiary);
border-radius: 0.25rem;
transition: all 0.2s;
}
.checkmark:after {
content: "";
position: absolute;
display: none;
left: 0.3125rem;
top: 0.125rem;
width: 0.3125rem;
height: 0.625rem;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
input:checked + .checkmark {
background-color: var(--accent-color);
border-color: var(--accent-color);
}
input:checked + .checkmark:after {
display: block;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 2rem;
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.action-button.cancel {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.action-button.cancel:hover {
background-color: var(--bg-tertiary);
filter: brightness(0.95);
}
.action-button.save {
background-color: var(--accent-color);
color: white;
}
.action-button.save:hover {
background-color: var(--accent-hover);
}
.action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 768px) {
.popup-container {
width: 95%;
max-height: 95vh;
}
.pool-header {
padding: 0.75rem;
}
.pool-info {
gap: 0.75rem;
}
.pool-image {
width: 2.5rem;
height: 2.5rem;
}
.map-item {
padding: 0.5rem;
gap: 0.75rem;
}
.map-cover {
width: 2rem;
height: 2rem;
}
.form-actions {
flex-direction: column;
gap: 0.5rem;
}
.action-button {
width: 100%;
justify-content: center;
}
}
</style>

View file

@ -6,6 +6,7 @@
import { v4 as uuidv4 } from "uuid";
import { onMount } from "svelte";
import type { BeatSaverMap } from "$lib/services/beatsaver";
import { type Modifier, allGameModifiers } from "$lib/services/gamePlayModifiers";
export let tournamentGuid: string;
export let mode: 'qualifier' | 'playing' = 'playing';
@ -38,177 +39,18 @@
// Qualifier-specific settings
let limitAttemptsEnabled = false;
let numberOfAttempts = 1;
let disableScoresaberSubmission = false;
let showScoreboard = false;
// Tournament Assistant settings for both modes
let disableFail = false;
let disablePause = false;
let disableScoresaberSubmission = false;
// Playing-specific settings
let disableCustomNotesOnStream = false;
// Beat Saber Modifiers with incompatibility groups
interface Modifier {
id: GameplayModifiers_GameOptions;
name: string;
description: string;
enabled: boolean;
group: string;
incompatibilityGroup?: string; // Modifiers in same group are mutually exclusive
allowedInMode?: ('qualifier' | 'playing')[];
}
enum MapDifficulty {
"Easy" = 0,
"Normal" = 1,
"Hard" = 2,
"Expert" = 3,
"Expert+" = 4
}
// Game modifiers with proper grouping and mode restrictions
let gameModifiers: Modifier[] = [
// Failure Prevention (mutually exclusive)
{
id: GameplayModifiers_GameOptions.NoFail,
name: "No Fail",
description: "You can't fail the level",
enabled: false,
group: "Failure Prevention",
incompatibilityGroup: "failure",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.InstaFail,
name: "One Life",
description: "You only have one life",
enabled: false,
group: "Failure Prevention",
incompatibilityGroup: "failure",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.BatteryEnergy,
name: "Four Lives",
description: "You have four lives",
enabled: false,
group: "Failure Prevention",
incompatibilityGroup: "failure",
allowedInMode: ['qualifier', 'playing']
},
// Environment Modifiers
{
id: GameplayModifiers_GameOptions.NoBombs,
name: "No Bombs",
description: "No bombs will appear",
enabled: false,
group: "Environment",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.NoObstacles,
name: "No Walls",
description: "No walls will appear",
enabled: false,
group: "Environment",
allowedInMode: ['qualifier', 'playing']
},
// Note Modifiers (some mutually exclusive)
{
id: GameplayModifiers_GameOptions.NoArrows,
name: "No Arrows",
description: "All notes can be cut in any direction",
enabled: false,
group: "Note Modifiers",
incompatibilityGroup: "arrows",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.DisappearingArrows,
name: "Disappearing Arrows",
description: "Arrows disappear as they approach you",
enabled: false,
group: "Note Modifiers",
incompatibilityGroup: "arrows",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.GhostNotes,
name: "Ghost Notes",
description: "Note colors are hidden",
enabled: false,
group: "Note Modifiers",
allowedInMode: ['qualifier', 'playing']
},
// Speed Modifiers (mutually exclusive)
{
id: GameplayModifiers_GameOptions.SlowSong,
name: "Slower Song",
description: "Reduces the song speed by 15%",
enabled: false,
group: "Speed",
incompatibilityGroup: "speed",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.FastSong,
name: "Faster Song",
description: "Increases the song speed by 20%",
enabled: false,
group: "Speed",
incompatibilityGroup: "speed",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.SuperFastSong,
name: "Super Fast Song",
description: "Increases the song speed by 50%",
enabled: false,
group: "Speed",
incompatibilityGroup: "speed",
allowedInMode: ['qualifier', 'playing']
},
// Visual Modifiers (some mutually exclusive)
{
id: GameplayModifiers_GameOptions.SmallCubes,
name: "Small Notes",
description: "Notes are smaller",
enabled: false,
group: "Visual",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.ProMode,
name: "Pro Mode",
description: "Makes notes smaller, removes debris, and adds hit scores",
enabled: false,
group: "Visual",
allowedInMode: ['qualifier', 'playing']
},
// Special Modifiers
{
id: GameplayModifiers_GameOptions.ZenMode,
name: "Zen Mode",
description: "No fail, no bombs, reduced obstacles",
enabled: false,
group: "Special",
allowedInMode: ['playing'] // Only for playing mode
},
{
id: GameplayModifiers_GameOptions.StrictAngles,
name: "Strict Angles",
description: "Stricter angle enforcement for cuts",
enabled: false,
group: "Special",
allowedInMode: ['qualifier', 'playing']
}
];
let gameModifiers: Modifier[] = allGameModifiers;
// Initialize from existing map data
function initializeFromMap() {

View file

@ -0,0 +1,828 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { type Map, GameplayModifiers_GameOptions, Push_SongFinished } from 'moons-ta-client';
import type { BeatSaverMap } from '$lib/services/beatsaver.js';
import { type ScoreWithAccuracy, type PreviousResults, MapDifficulty } from '$lib/interfaces/match.interfaces';
export let mapData: PreviousResults | null;
export let isVisible: boolean = false;
const dispatch = createEventDispatcher();
// Copy options
let includeMapInfo = true;
let includeEndTime = false;
let includeMisses = true;
let includeBadCuts = true;
let includeAccuracy = true;
// Modifiers mapping
const modifierNameMap: Record<number, string> = {
[GameplayModifiers_GameOptions.None]: "",
[GameplayModifiers_GameOptions.NoFail]: "No Fail",
[GameplayModifiers_GameOptions.NoBombs]: "No Bombs",
[GameplayModifiers_GameOptions.NoArrows]: "No Arrows",
[GameplayModifiers_GameOptions.NoObstacles]: "No Walls",
[GameplayModifiers_GameOptions.SlowSong]: "Slower Song",
[GameplayModifiers_GameOptions.InstaFail]: "One Life",
[GameplayModifiers_GameOptions.FailOnClash]: "Fail on Clash",
[GameplayModifiers_GameOptions.BatteryEnergy]: "Four Lives",
[GameplayModifiers_GameOptions.FastNotes]: "Fast Notes",
[GameplayModifiers_GameOptions.FastSong]: "Faster Song",
[GameplayModifiers_GameOptions.DisappearingArrows]: "Disappearing Arrows",
[GameplayModifiers_GameOptions.GhostNotes]: "Ghost Notes",
[GameplayModifiers_GameOptions.DemoNoFail]: "Demo No Fail",
[GameplayModifiers_GameOptions.DemoNoObstacles]: "Demo No Obstacles",
[GameplayModifiers_GameOptions.StrictAngles]: "Strict Angles",
[GameplayModifiers_GameOptions.ProMode]: "Pro Mode",
[GameplayModifiers_GameOptions.ZenMode]: "Zen Mode",
[GameplayModifiers_GameOptions.SmallCubes]: "Small Notes",
[GameplayModifiers_GameOptions.SuperFastSong]: "Super Fast Song"
};
function closePopup() {
dispatch('close');
}
function getActiveModifiers(gameplayModifiers: number): string[] {
const activeModifiers: string[] = [];
const values = Object.values(GameplayModifiers_GameOptions)
.filter((value): value is number =>
typeof value === 'number' &&
value !== GameplayModifiers_GameOptions.None &&
value > 0
)
.sort((a, b) => a - b);
for (const value of values) {
if ((gameplayModifiers & value) === value) {
const name = modifierNameMap[value];
if (name && name.trim() !== "") {
activeModifiers.push(name);
}
}
}
return activeModifiers;
}
function getDifficultyName(difficulty: number): string {
return MapDifficulty[difficulty] || 'Unknown';
}
function formatTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
function formatBPM(bpm: number): string {
return Math.round(bpm).toString();
}
function getCompletionStatus(score: ScoreWithAccuracy, songDuration: number): string {
const timeDifference = Math.abs(score.endTime - songDuration);
if (timeDifference <= 2) {
return 'Completed';
} else {
return `Exited early at: ${formatTime(score.endTime)}`;
}
}
function isCompleted(score: ScoreWithAccuracy, songDuration: number): boolean {
const timeDifference = Math.abs(score.endTime - songDuration);
return timeDifference <= 2;
}
function copyScoresToClipboard() {
const mapName = mapData!.beatsaverData.name || 'Unknown Song';
const artist = mapData!.beatsaverData.metadata?.songAuthorName || 'Unknown Artist';
const difficulty = getDifficultyName(mapData!.taData.gameplayParameters?.beatmap?.difficulty || 0);
const songDuration = mapData!.beatsaverData.metadata?.duration || 0;
let discordMessage = '';
if (includeMapInfo) {
discordMessage += `## **${mapName}** by ${artist}\n`;
discordMessage += `**Difficulty:** ${difficulty}\n`;
const modifiers = getActiveModifiers(mapData!.taData.gameplayParameters?.gameplayModifiers?.options || 0);
if (modifiers.length > 0) {
discordMessage += `**Modifiers:** ${modifiers.join(', ')}\n`;
}
discordMessage += '\n';
}
// Sort scores by total score (descending)
const sortedScores = [...mapData!.scores].sort((a, b) => b.score - a.score);
sortedScores.forEach((score, index) => {
const position = index + 1;
const positionText = position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`;
const displayName = score.player?.name || score.player?.discordInfo?.username || 'Unknown Player';
discordMessage += `**${positionText} ${displayName}**\n`;
discordMessage += `Score: ${score.score.toLocaleString()}`;
if (includeAccuracy) {
discordMessage += ` | Accuracy: ${score.accuracy}%\n`;
}
if (includeEndTime) {
discordMessage += `-# `;
const completionStatus = getCompletionStatus(score, songDuration);
discordMessage += `Status: ${completionStatus}\n`;
}
if (includeMisses || includeBadCuts) {
let errorLine = '';
if (includeMisses) errorLine += `Misses: ${score.misses}`;
if (includeMisses && includeBadCuts) errorLine += ' | ';
if (includeBadCuts) errorLine += `Bad Cuts: ${score.badCuts}`;
discordMessage += `${errorLine}\n`;
}
discordMessage += '\n';
});
navigator.clipboard.writeText(discordMessage).then(() => {
console.log('Scores copied to clipboard');
});
}
$: activeModifiers = getActiveModifiers(mapData?.taData.gameplayParameters?.gameplayModifiers?.options || 0);
$: sortedScores = mapData?.scores ? [...mapData.scores].sort((a, b) => b.score - a.score) : [];
$: songDuration = mapData?.beatsaverData.metadata?.duration || 0;
$: isAwaitingScores = mapData?.completionType === 'Still Awaiting Scores';
</script>
{#if isVisible && mapData}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="popup-overlay" on:click={closePopup}>
<div class="popup-content" on:click|stopPropagation>
<!-- Header -->
<div class="popup-header">
<div class="header-content">
<div class="map-cover">
<img
src={mapData.beatsaverData.versions[0]?.coverURL || '/default-song-cover.png'}
alt="Map Cover"
/>
</div>
<div class="map-info">
<h2 class="map-title">{mapData.beatsaverData.name || 'Unknown Song'}</h2>
<p class="map-artist">{mapData.beatsaverData.metadata?.songAuthorName || 'Unknown Artist'}</p>
<p class="map-mapper">Mapped by {mapData.beatsaverData.metadata?.levelAuthorName || 'Unknown Mapper'}</p>
</div>
</div>
<button class="close-button" on:click={closePopup}>
<span class="material-icons">close</span>
</button>
</div>
<!-- Awaiting Scores Indicator -->
{#if isAwaitingScores}
<div class="awaiting-scores">
<div class="loader"></div>
<span>Still awaiting scores...</span>
</div>
{/if}
<!-- Map Details -->
<div class="map-details-section">
<h3>Map Details</h3>
<div class="details-grid">
<div class="detail-item">
<span class="material-icons">schedule</span>
<span class="detail-label">Duration</span>
<span class="detail-value">{formatTime(songDuration)}</span>
</div>
<div class="detail-item">
<span class="material-icons">speed</span>
<span class="detail-label">BPM</span>
<span class="detail-value">{formatBPM(mapData.beatsaverData.metadata?.bpm || 0)}</span>
</div>
<div class="detail-item">
<span class="material-icons">star</span>
<span class="detail-label">Difficulty</span>
<span class="detail-value difficulty-badge difficulty-{mapData.taData.gameplayParameters?.beatmap?.difficulty}">
{getDifficultyName(mapData.taData.gameplayParameters?.beatmap?.difficulty || 0)}
</span>
</div>
</div>
{#if activeModifiers.length > 0}
<div class="modifiers-section">
<h4>Active Modifiers</h4>
<div class="modifiers-list">
{#each activeModifiers as modifier}
<span class="modifier-tag">
<span class="material-icons">tune</span>
{modifier}
</span>
{/each}
</div>
</div>
{/if}
</div>
<!-- Copy Options -->
<div class="copy-options-section">
<h3>Copy Options</h3>
<div class="copy-controls">
<div class="toggle-row">
<label class="toggle-label">
<input type="checkbox" bind:checked={includeMapInfo} />
<span class="toggle-slider"></span>
Include Map Info
</label>
<label class="toggle-label">
<input type="checkbox" bind:checked={includeAccuracy} />
<span class="toggle-slider"></span>
Include Accuracy
</label>
</div>
<div class="toggle-row">
<label class="toggle-label">
<input type="checkbox" bind:checked={includeMisses} />
<span class="toggle-slider"></span>
Include Misses
</label>
<label class="toggle-label">
<input type="checkbox" bind:checked={includeBadCuts} />
<span class="toggle-slider"></span>
Include Bad Cuts
</label>
</div>
<div class="toggle-row">
<label class="toggle-label">
<input type="checkbox" bind:checked={includeEndTime} />
<span class="toggle-slider"></span>
Include End Time
</label>
</div>
</div>
<button class="copy-button" on:click={copyScoresToClipboard}>
<span class="material-icons">content_copy</span>
Copy Scores to Clipboard
</button>
</div>
<!-- Scores Section -->
<div class="scores-section">
<h3>Scores ({sortedScores.length})</h3>
{#if sortedScores.length === 0}
<div class="no-scores">
<span class="material-icons">leaderboard</span>
<p>No scores available</p>
</div>
{:else}
<div class="scores-list">
{#each sortedScores as score, index}
{@const position = index + 1}
{@const displayName = score.player?.name || score.player?.discordInfo?.username || 'Unknown Player'}
{@const completed = isCompleted(score, songDuration)}
<div class="score-card {completed ? 'completed' : 'exited-early'}">
<div class="score-position">
<span class="position-number">{position}</span>
<span class="position-suffix">{position === 1 ? 'st' : position === 2 ? 'nd' : position === 3 ? 'rd' : 'th'}</span>
</div>
<div class="score-info">
<div class="player-name">{displayName}</div>
<div class="score-stats">
<div class="stat-item primary">
<span class="material-icons">emoji_events</span>
<span class="stat-value">{score.score.toLocaleString()}</span>
</div>
<div class="stat-item">
<span class="material-icons">percent</span>
<span class="stat-value">{score.accuracy}%</span>
</div>
<div class="stat-item error">
<span class="material-icons">close</span>
<span class="stat-value">{score.misses}</span>
</div>
<div class="stat-item error">
<span class="material-icons">content_cut</span>
<span class="stat-value">{score.badCuts}</span>
</div>
</div>
<div class="completion-status">
<span class="material-icons">
{completed ? 'check_circle' : 'exit_to_app'}
</span>
<span class="status-text">
{getCompletionStatus(score, songDuration)}
</span>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.popup-content {
background-color: var(--bg-secondary);
border-radius: 1rem;
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-bottom: 1px solid var(--bg-tertiary);
}
.header-content {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
}
.map-cover {
width: 4rem;
height: 4rem;
border-radius: 0.5rem;
overflow: hidden;
flex-shrink: 0;
}
.map-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.map-info {
flex: 1;
min-width: 0;
}
.map-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
color: var(--text-primary);
}
.map-artist {
font-size: 1rem;
color: var(--text-secondary);
margin: 0 0 0.25rem 0;
}
.map-mapper {
font-size: 0.875rem;
color: var(--text-tertiary);
margin: 0;
}
.close-button {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.375rem;
transition: all 0.2s ease;
}
.close-button:hover {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.awaiting-scores {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
background-color: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
border-left: 4px solid #F59E0B;
margin: 0 1.5rem;
border-radius: 0.5rem;
color: #F59E0B;
font-weight: 500;
}
.loader {
width: 1rem;
height: 1rem;
border: 2px solid rgba(245, 158, 11, 0.3);
border-top: 2px solid #F59E0B;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.map-details-section,
.copy-options-section,
.scores-section {
padding: 1.5rem;
border-bottom: 1px solid var(--bg-tertiary);
}
.scores-section {
border-bottom: none;
}
.map-details-section h3,
.copy-options-section h3,
.scores-section h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 1rem 0;
color: var(--text-primary);
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.detail-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background-color: var(--bg-tertiary);
border-radius: 0.5rem;
}
.detail-item .material-icons {
color: var(--accent-color);
font-size: 1.25rem;
}
.detail-label {
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
flex: 1;
}
.detail-value {
color: var(--text-primary);
font-weight: 600;
}
.difficulty-badge {
padding: 0.25rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.difficulty-0 { background-color: #10B981; color: white; }
.difficulty-1 { background-color: #3B82F6; color: white; }
.difficulty-2 { background-color: #F59E0B; color: white; }
.difficulty-3 { background-color: #EF4444; color: white; }
.difficulty-4 { background-color: #8B5CF6; color: white; }
.modifiers-section h4 {
font-size: 1rem;
font-weight: 500;
margin: 0 0 0.75rem 0;
color: var(--text-primary);
}
.modifiers-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.modifier-tag {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background-color: rgba(79, 70, 229, 0.1);
color: var(--accent-color);
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid rgba(79, 70, 229, 0.2);
}
.modifier-tag .material-icons {
font-size: 1rem;
}
.copy-controls {
margin-bottom: 1.5rem;
}
.toggle-row {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
}
.toggle-label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
color: var(--text-primary);
font-weight: 500;
}
.toggle-label input[type="checkbox"] {
display: none;
}
.toggle-slider {
position: relative;
width: 2.5rem;
height: 1.5rem;
background-color: var(--bg-tertiary);
border-radius: 0.75rem;
transition: background-color 0.2s ease;
}
.toggle-slider::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 1.25rem;
height: 1.25rem;
background-color: white;
border-radius: 50%;
transition: transform 0.2s ease;
}
.toggle-label input[type="checkbox"]:checked + .toggle-slider {
background-color: var(--accent-color);
}
.toggle-label input[type="checkbox"]:checked + .toggle-slider::before {
transform: translateX(1rem);
}
.copy-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background-color: var(--accent-color);
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 0 0 0 var(--accent-glow);
}
.copy-button:hover {
background-color: var(--accent-hover);
box-shadow: 0 0 15px var(--accent-glow);
transform: translateY(-1px);
}
.no-scores {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
color: var(--text-secondary);
gap: 0.75rem;
text-align: center;
}
.no-scores .material-icons {
font-size: 3rem;
opacity: 0.6;
}
.scores-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.score-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background-color: var(--bg-tertiary);
border-radius: 0.75rem;
transition: all 0.2s ease;
border-left: 4px solid transparent;
}
.score-card.completed {
border-left-color: #10B981;
}
.score-card.exited-early {
border-left-color: #EF4444;
}
.score-card:hover {
background-color: var(--bg-primary);
transform: translateY(-1px);
}
.score-position {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background-color: var(--bg-secondary);
border-radius: 0.5rem;
flex-shrink: 0;
}
.position-number {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.position-suffix {
font-size: 0.625rem;
color: var(--text-secondary);
line-height: 1;
}
.score-info {
flex: 1;
min-width: 0;
}
.player-name {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.score-stats {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 0.5rem;
}
.stat-item {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
background-color: var(--bg-secondary);
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
}
.stat-item.primary {
background-color: rgba(251, 191, 36, 0.1);
color: #F59E0B;
}
.stat-item.error {
background-color: rgba(239, 68, 68, 0.1);
color: #EF4444;
}
.stat-item .material-icons {
font-size: 1rem;
}
.completion-status {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.completion-status .material-icons {
font-size: 1rem;
}
.score-card.completed .completion-status {
color: #10B981;
}
.score-card.exited-early .completion-status {
color: #EF4444;
}
.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;
}
/* Scrollbar Styling */
.popup-content::-webkit-scrollbar {
width: 0.5rem;
}
.popup-content::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 0.25rem;
}
.popup-content::-webkit-scrollbar-thumb {
background: var(--text-secondary);
border-radius: 0.25rem;
}
.popup-content::-webkit-scrollbar-thumb:hover {
background: var(--text-primary);
}
/* Responsive Design */
@media (max-width: 768px) {
.popup-overlay {
padding: 0.5rem;
}
.popup-content {
max-height: 95vh;
}
.header-content {
flex-direction: column;
text-align: center;
gap: 0.75rem;
}
.details-grid {
grid-template-columns: 1fr;
}
.toggle-row {
flex-direction: column;
gap: 1rem;
}
.score-stats {
flex-direction: column;
gap: 0.5rem;
}
.score-card {
flex-direction: column;
text-align: center;
gap: 0.75rem;
}
}
</style>

View file

@ -1,6 +1,7 @@
{
"discordAuthUrl": "https://discord.com/oauth2/authorize?client_id=1348291960552820757&response_type=token&redirect_uri=http%3A%2F%2Flocalhost%3A1420%2FdiscordAuth&scope=identify",
"discordAuthUrl": "https://discord.com/oauth2/authorize?client_id=1348291960552820757&response_type=token&redirect_uri=https%3A%2F%2Fstaging.taui.shyyluna.dev%2FdiscordAuth&scope=identify",
"bkAPIUrl": "https://api.beatkhana.com/api",
"prodAuthUrl": "https://discord.com/oauth2/authorize?client_id=1348291960552820757&response_type=token&redirect_uri=https%3A%2F%2Ftaui.shyyluna.dev%2FdiscordAuth&scope=identify",
"stagingAuthUrl": "https://discord.com/oauth2/authorize?client_id=1348291960552820757&response_type=token&redirect_uri=https%3A%2F%2Fstaging.taui.shyyluna.dev%2FdiscordAuth&scope=identify"
"stagingAuthUrl": "https://discord.com/oauth2/authorize?client_id=1348291960552820757&response_type=token&redirect_uri=https%3A%2F%2Fstaging.taui.shyyluna.dev%2FdiscordAuth&scope=identify",
"devAuthUrl": "https://discord.com/oauth2/authorize?client_id=1348291960552820757&response_type=token&redirect_uri=http%3A%2F%2Flocalhost%3A1420%2FdiscordAuth&scope=identify"
}

View file

@ -0,0 +1,61 @@
// src/lib/types/match.ts
import type { Map, User, RealtimeScore, Push_SongFinished } from 'moons-ta-client';
import type { BeatSaverMap } from '$lib/services/beatsaver.js';
export interface CustomMap {
taData: Map;
beatsaverData: BeatSaverMap;
}
export interface RealTimeScoreForPlayers {
player: User;
recentScore: RealtimeScore;
}
export interface ScoreWithAccuracy extends Push_SongFinished {
accuracy: string;
}
export interface PreviousResults {
taData: Map;
beatsaverData: BeatSaverMap;
scores: ScoreWithAccuracy[];
completionType: 'Completed' | 'Still Awaiting Scores' | 'Exited To Menu';
}
export enum MapDifficulty {
"Easy" = 0,
"Normal" = 1,
"Hard" = 2,
"Expert" = 3,
"ExpertPlus" = 4,
}
export const colorPresets = {
primary: 'var(--accent-glow)',
secondary: 'var(--text-secondary)',
success: '#4CAF50',
warning: '#FFC107',
error: '#F44336',
info: '#2196F3',
default: 'var(--text-primary)'
} as const;
export type ColorPreset = keyof typeof colorPresets;
export type ButtonType = 'primary' | 'secondary' | 'text' | 'outlined';
export interface ButtonConfig {
text: string;
type: ButtonType;
href?: string;
color?: ColorPreset;
icon?: string;
action?: () => void;
}
export interface CustomTAMapPool {
guid: string;
name: string;
image: Uint8Array;
maps: CustomMap[];
}

View file

@ -0,0 +1,155 @@
import { Response_ResponseType, Map, Characteristic, GameplayModifiers_GameOptions } from 'moons-ta-client';
// Beat Saber Modifiers with incompatibility groups
export interface Modifier {
id: GameplayModifiers_GameOptions;
name: string;
description: string;
enabled: boolean;
group: string;
incompatibilityGroup?: string; // Modifiers in same group are mutually exclusive
allowedInMode?: ('qualifier' | 'playing')[];
}
// Game modifiers with proper grouping and mode restrictions
export const allGameModifiers: Modifier[] = [
// Failure Prevention (mutually exclusive)
{
id: GameplayModifiers_GameOptions.NoFail,
name: "No Fail",
description: "You can't fail the level",
enabled: false,
group: "Failure Prevention",
incompatibilityGroup: "failure",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.InstaFail,
name: "One Life",
description: "You only have one life",
enabled: false,
group: "Failure Prevention",
incompatibilityGroup: "failure",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.BatteryEnergy,
name: "Four Lives",
description: "You have four lives",
enabled: false,
group: "Failure Prevention",
incompatibilityGroup: "failure",
allowedInMode: ['qualifier', 'playing']
},
// Environment Modifiers
{
id: GameplayModifiers_GameOptions.NoBombs,
name: "No Bombs",
description: "No bombs will appear",
enabled: false,
group: "Environment",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.NoObstacles,
name: "No Walls",
description: "No walls will appear",
enabled: false,
group: "Environment",
allowedInMode: ['qualifier', 'playing']
},
// Note Modifiers (some mutually exclusive)
{
id: GameplayModifiers_GameOptions.NoArrows,
name: "No Arrows",
description: "All notes can be cut in any direction",
enabled: false,
group: "Note Modifiers",
incompatibilityGroup: "arrows",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.DisappearingArrows,
name: "Disappearing Arrows",
description: "Arrows disappear as they approach you",
enabled: false,
group: "Note Modifiers",
incompatibilityGroup: "arrows",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.GhostNotes,
name: "Ghost Notes",
description: "Note colors are hidden",
enabled: false,
group: "Note Modifiers",
allowedInMode: ['qualifier', 'playing']
},
// Speed Modifiers (mutually exclusive)
{
id: GameplayModifiers_GameOptions.SlowSong,
name: "Slower Song",
description: "Reduces the song speed by 15%",
enabled: false,
group: "Speed",
incompatibilityGroup: "speed",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.FastSong,
name: "Faster Song",
description: "Increases the song speed by 20%",
enabled: false,
group: "Speed",
incompatibilityGroup: "speed",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.SuperFastSong,
name: "Super Fast Song",
description: "Increases the song speed by 50%",
enabled: false,
group: "Speed",
incompatibilityGroup: "speed",
allowedInMode: ['qualifier', 'playing']
},
// Visual Modifiers (some mutually exclusive)
{
id: GameplayModifiers_GameOptions.SmallCubes,
name: "Small Notes",
description: "Notes are smaller",
enabled: false,
group: "Visual",
allowedInMode: ['qualifier', 'playing']
},
{
id: GameplayModifiers_GameOptions.ProMode,
name: "Pro Mode",
description: "Makes notes smaller, removes debris, and adds hit scores",
enabled: false,
group: "Visual",
allowedInMode: ['qualifier', 'playing']
},
// Special Modifiers
{
id: GameplayModifiers_GameOptions.ZenMode,
name: "Zen Mode",
description: "No fail, no bombs, reduced obstacles",
enabled: false,
group: "Special",
allowedInMode: ['playing'] // Only for playing mode
},
{
id: GameplayModifiers_GameOptions.StrictAngles,
name: "Strict Angles",
description: "Stricter angle enforcement for cuts",
enabled: false,
group: "Special",
allowedInMode: ['qualifier', 'playing']
}
];

View file

@ -8,7 +8,10 @@
import Popup from "$lib/components/notifications/Popup.svelte";
import InfoPopup from "$lib/components/notifications/InfoPopup.svelte";
import EditMapModal from "$lib/components/popups/EditMap.svelte";
import AddMapsFromFile from "$lib/components/popups/AddMapsFromFile.svelte";
import { type BeatSaverMap } from "$lib/services/beatsaver.js";
import { goto } from "$app/navigation";
import type { CustomMap } from "$lib/interfaces/match.interfaces.js";
export let data;
@ -19,37 +22,7 @@
let isLoading = false;
let error: string | null = null;
let poolName: string = "";
let maps: {
taData: Map,
beatsaverData: BeatSaverMap
}[] = [];
type BeatSaverMap = {
id: string;
metadata: {
songName: string;
songAuthorName: string;
levelAuthorName: string;
songSubName: string;
duration: number;
bpm: number;
};
versions: Array<{
hash: string;
state: string;
createdAt: string;
downloadURL: string;
diffs: Array<{
characteristic: string;
difficulty: string;
maxScore: number;
nps: number;
}>;
coverURL: string;
previewURL: string;
}>;
uploaded: string;
};
let maps: CustomMap[] = [];
enum MapDifficulty {
"Easy" = 0,
@ -59,17 +32,6 @@
"Expert+" = 4
}
interface Modifier {
id: GameplayModifiers_GameOptions;
name: string;
description: string;
enabled: boolean;
disabled?: boolean;
group?: string;
incompatibilityGroup?: string;
allowedInMode: string[];
}
const modifierNameMap: Record<number, string> = {
[GameplayModifiers_GameOptions.None]: "",
[GameplayModifiers_GameOptions.NoFail]: "No Fail",
@ -109,6 +71,9 @@
let showSuccessNotification = false;
let successMessage = "";
// Import maps popup
let showLoadMapsFromFilePopup: boolean = false;
// Remove 'Bearer ' from the token
if ($authTokenStore) {
const cleanToken = $authTokenStore.replace('Bearer ', '');
@ -134,6 +99,14 @@
currentEditMap = null;
}
function openLoadMapsFromFilePopup() {
showLoadMapsFromFilePopup = true;
}
function closeLoadMapsFromFilePopup() {
showLoadMapsFromFilePopup = false;
}
function openDeleteConfirmation(mapId: string) {
mapToDelete = mapId;
showDeleteConfirmation = true;
@ -148,6 +121,21 @@
showSuccessNotification = false;
}
async function handleAddMapsFromFile(event: CustomEvent) {
const toAddMaps = (event.detail.maps as CustomMap[]);
const taDatasToAdd = await toAddMaps.map((map) => map.taData);
const addResponse = await client.addTournamentPoolMaps(tournamentGuid, poolGuid, taDatasToAdd);
console.log('Added multiple maps response:', addResponse);
fetchMapPoolData();
showSuccessNotification = true;
setTimeout(() => {
showSuccessNotification = false;
}, 2500);
successMessage = "Map successfully updated!";
}
async function handleMapUpdated(event: CustomEvent) {
await client.updateTournamentPoolMap(tournamentGuid, poolGuid, event.detail.map);
fetchMapPoolData();
@ -331,6 +319,10 @@
<span class="material-icons">add</span>
Add Map
</button>
<button class="action-button add" on:click={() => openLoadMapsFromFilePopup()}>
<span class="material-icons">add</span>
Load Maps From File
</button>
<button class="action-button refresh" on:click={fetchMapPoolData}>
<span class="material-icons">refresh</span>
Refresh
@ -446,8 +438,7 @@
{#if showEditMapModal}
<EditMapModal
tournamentGuid={tournamentGuid}
poolGuid={poolGuid}
map={maps.find(map => map.taData.guid == currentEditMap?.guid) || null}
map={maps.filter(map => map.taData.guid == currentEditMap?.guid)[0] || null}
on:close={closeEditMapModal}
on:mapUpdated={handleMapUpdated}
on:mapAdded={handleMapAdded}
@ -455,6 +446,13 @@
/>
{/if}
<!-- Add Maps From File Modal Component -->
<AddMapsFromFile
isOpen={showLoadMapsFromFilePopup}
on:close={closeLoadMapsFromFilePopup}
on:addMaps={handleAddMapsFromFile}
/>
<!-- Info Popup -->
{#if showInfoPopup}
<InfoPopup content={infoPopupContent} onClose={closeInfoPopup} />

View file

@ -4,43 +4,51 @@
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 } from 'moons-ta-client';
import {
Match,
Tournament,
TAClient,
Response_ResponseType,
User,
RealtimeScore,
Push_SongFinished,
Map,
User_DownloadStates,
User_PlayStates,
Tournament_TournamentSettings_Pool
} from 'moons-ta-client';
import { writable } from "svelte/store";
import { goto } from "$app/navigation";
import { fetchMapByLevelId, type BeatSaverMap } from "$lib/services/beatsaver.js";
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";
// Types
import {
type CustomMap,
type RealTimeScoreForPlayers,
type ScoreWithAccuracy,
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;
interface CustomMap {
taData: Map,
beatsaverData: BeatSaverMap
}
interface RealTimeScoreForPlayers {
player: User,
recentScore: RealtimeScore
}
interface ScoreWithAccuracy extends Push_SongFinished {
accuracy: number;
}
interface PreviousResults {
taData: Map;
beatsaverData: BeatSaverMap;
scores: ScoreWithAccuracy[];
completionType: 'Completed' | 'Still Awaiting Scores' | 'Exited To Menu';
}
let upcomingQueueMaps: CustomMap[] = [];
// 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;
@ -51,29 +59,11 @@
let isMatchActive = false;
let streamSyncEnabled = false;
// Song queue and results
let upcomingQueueMaps: CustomMap[] = [];
let realTimeScores: RealTimeScoreForPlayers[] = [];
let previousMatchResults: PreviousResults[] = [];
const colorPresets = {
primary: 'var(--accent-glow)',
secondary: 'var(--text-secondary)',
success: '#4CAF50',
warning: '#FFC107',
error: '#F44336',
info: '#2196F3',
default: 'var(--text-primary)'
};
type ColorPreset = keyof typeof colorPresets;
type ButtonType = 'primary' | 'secondary' | 'text' | 'outlined';
interface ButtonConfig {
text: string;
type: ButtonType;
href?: string;
color?: ColorPreset;
icon?: string;
action?: () => void;
}
let showingResultsData: PreviousResults;
// Modal states
let showKickConfirmModal = false;
@ -86,30 +76,60 @@
let modalIconColor: ColorPreset = 'error';
let modalButtons: ButtonConfig[] = [];
let modalCustomImage: string = "";
let modalAutoClose: number = 0; // no auto close
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;
// Active song tracking
const activeSongPlayers = new Set<`${string}-${string}`>(); // Format: "userGuid-levelId"
// Remove 'Bearer ' from the token
if($authTokenStore) {
// Reactive statements
$: isAnyPlayerPlaying = matchPlayers.some(player => (player.playState as any) === User_PlayStates.InGame);
if ($authTokenStore) {
const cleanToken = $authTokenStore.replace('Bearer ', '');
client.setAuthToken(cleanToken);
}
$: isAnyPlayerPlaying = matchPlayers.some(player => (player.playState as any) === User_PlayStates.InGame);
// Show notification function (you mentioned you have this)
// 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()) {
@ -344,11 +364,74 @@
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.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() {
@ -399,6 +482,9 @@
async function startSongForMatch() {
if (!currentSong) return;
showResultsPopup = false;
dontShowResultsPopupForThisSong = false;
const levelId = currentSong.beatmap.levelId;
const playingPlayerIds = matchPlayers.map(player => player.guid);
@ -454,6 +540,8 @@
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) {
@ -495,6 +583,39 @@
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): string {
const maxScore = getMaxScore(
mapWithSongInfo.beatsaverData,
score.beatmap?.characteristic?.serializedName ?? "Standard",
getDifficultyAsString(score.beatmap?.difficulty ?? 4) || "ExpertPlus",
);
const accuracy = ((score.score / maxScore) * 100).toFixed(2);
return accuracy;
}
async function handleSongFinished(rts: Push_SongFinished) {
if (rts.matchId !== matchGuid) return;
@ -513,7 +634,8 @@
return;
}
const newRts: ScoreWithAccuracy = {...rts, accuracy: 0};
let newRts: ScoreWithAccuracy = {...rts, accuracy: "0"};
newRts.accuracy = calculateAccuracy(rts, map);
// Check if any players are still playing this song
const playersStillPlaying = Array.from(activeSongPlayers)
@ -551,6 +673,9 @@
...previousMatchResults.filter((_, i) => i !== existingRecordIndex)
];
}
showingResultsData = previousMatchResults[0];
showResultsPopup = true;
}
async function handleMapUpdated(event: CustomEvent) {
@ -582,6 +707,23 @@
];
}
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;
}
@ -606,6 +748,7 @@
const map: CustomMap = event.detail.map;
loadingSongForPlayers = false;
failedToLoadSongForPlayers = false;
dontShowResultsPopupForThisSong = false;
let playingPlayerIds = matchPlayers
.filter(player => player.playState !== User_PlayStates.InGame)
@ -704,7 +847,7 @@
End Match
</button>
<div class="match-info">
<h2>Match: {'Unknown'}</h2>
<h2>Match: {match?.guid || 'Undefined'}</h2>
<p class="tournament-name">{tournament?.settings?.tournamentName}</p>
</div>
</div>
@ -865,6 +1008,7 @@
on:editMap={handleEditMap}
on:removeMap={handleRemoveMapFromQueue}
on:songLoad={handleLoadMap}
on:addMapsFromTAPool={handleShowAddMapsFromTAPool}
/>
</div>
@ -872,6 +1016,7 @@
<div class="queue-section">
<PreviouslyPlayedSongs
maps={previousMatchResults}
on:showMapDetails={showResultsPopupWithOldScores}
/>
</div>
</div>
@ -901,6 +1046,21 @@
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}
<style>
.match-dashboard {
display: flex;
@ -1048,6 +1208,8 @@
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 */
@ -1111,8 +1273,8 @@
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 50vh;
overflow-y: auto;
max-height: 20rem;
overflow-y: scroll;
}
.player-card {