From 01aa843d0bf563bc0038f9dff12aa2b273f1a353 Mon Sep 17 00:00:00 2001 From: Lanta Date: Tue, 15 Apr 2025 17:12:33 +0200 Subject: [PATCH] AI --- demo.php | 244 ++++++---- discord/report_new_matches.ps1 | 842 ++++++++++++++++++++------------- discord/report_to_discord.ps1 | 226 ++++++--- discord/teammakerv2.py | 696 +++++++++++++++++++-------- includes/ps1/lockfile.ps1 | 102 ++-- includes/ratelimiter.php | 42 +- index.php | 284 +++++++---- last_stats.php | 319 +++++++------ latestmatches.php | 240 +++++++--- lib/sorttable.js | 678 ++++++++++---------------- matchinfo.php | 361 +++++++------- topstats.php | 151 ++++-- topstatsavg.php | 150 ++++-- update/get_matches.ps1 | 544 ++++++++++++++++----- update/matchparser.ps1 | 790 +++++++++++++++++++++---------- update/update_clan.ps1 | 212 +++++++-- update/update_clan_members.ps1 | 477 ++++++++++++------- user_stats.php | 157 ++++-- 18 files changed, 4122 insertions(+), 2393 deletions(-) diff --git a/demo.php b/demo.php index 387e0ca..77600ed 100644 --- a/demo.php +++ b/demo.php @@ -1,122 +1,166 @@ 25, "label" => "Sunday"), - array("y" => 15, "label" => "Monday"), - array("y" => 25, "label" => "Tuesday"), - array("y" => 5, "label" => "Wednesday"), - array("y" => 10, "label" => "Thursday"), - array("y" => 0, "label" => "Friday"), - array("y" => 20, "label" => "Saturday") -); - -?> -format('m-d'); // Month-Day format - - // Initialize player array if not existing - if (!isset($dataPointsPerPlayer[$playername])) { - $dataPointsPerPlayer[$playername] = []; + + // Basic validation of JSON structure + if (is_array($jsonArray) && isset($jsonArray['stats']['playername'], $jsonArray['created'], $jsonArray['winplace'])) { + $playername = $jsonArray['stats']['playername']; + $winplace = $jsonArray['winplace']; + $createdTimestamp = $jsonArray['created']; + + // Process the date + try { + $date = new DateTime($createdTimestamp); + $label = $date->format('m-d'); // Month-Day format + + // Initialize player array if not existing + if (!isset($dataPointsPerPlayer[$playername])) { + $dataPointsPerPlayer[$playername] = []; + } + + // Add to dataPoints for this player + $dataPointsPerPlayer[$playername][] = [ + "y" => $winplace, + "label" => $label + ]; + } catch (Exception $e) { + // Log or handle date parsing error + error_log("Could not parse date '$createdTimestamp' in file: " . $filepath); + continue; + } + } else { + // Log or handle invalid JSON structure + error_log("Invalid JSON structure or missing keys in file: " . $filepath); + continue; } - - // Add to dataPoints for this player - $dataPointsPerPlayer[$playername][] = array( - "y" => $jsonArray['winplace'], - "label" => $label - ); + } + closedir($handle); + + // Prepare data for the chart if processing was successful + if (empty($dataError) && !empty($dataPointsPerPlayer)) { + foreach ($dataPointsPerPlayer as $player => $dataPoints) { + // Sort data points by label (date) for each player + usort($dataPoints, function($a, $b) { + return strcmp($a['label'], $b['label']); + }); + + $chartData[] = [ + 'type' => 'line', + 'showInLegend' => true, + 'legendText' => htmlspecialchars($player), // Escape player name for legend + 'dataPoints' => $dataPoints + ]; + } + // Encode the final chart data + $encodedChartData = json_encode($chartData, JSON_NUMERIC_CHECK); + if ($encodedChartData === false) { + $dataError = "Failed to encode chart data into JSON."; + $encodedChartData = '[]'; // Reset to empty array on encoding failure + } + } elseif (empty($dataError)) { + $dataError = "No valid data found in '$directory' to generate chart."; } } - closedir($handle); } -// Now, $dataPointsPerPlayer is an array that contains an array of data points for each player -// You can access the data for a specific player like this: -// $playerData = $dataPointsPerPlayer['Lanta01']; - -// Output the array for debugging purposes -// At the end of your PHP script, where you previously printed the array -// Instead, we will encode the $dataPointsPerPlayer array into JSON -$chartData = []; -foreach ($dataPointsPerPlayer as $player => $dataPoints) { - $chartData[] = [ - 'type' => 'line', - 'showInLegend' => true, - 'legendText' => $player, - 'dataPoints' => $dataPoints - ]; -} - -// Store the JSON encoded data in a PHP variable -$encodedChartData = json_encode($chartData, JSON_NUMERIC_CHECK); - -// You can then pass this to your JavaScript code like this -echo ""; - - +// Note: The original $dataPoints array with days of the week was unused, so it's removed. ?> - - - - - + + Player Win Place Over Time + + -
- + + +

+ +
+ + + - \ No newline at end of file + \ No newline at end of file diff --git a/discord/report_new_matches.ps1 b/discord/report_new_matches.ps1 index d7a242e..c6dd484 100644 --- a/discord/report_new_matches.ps1 +++ b/discord/report_new_matches.ps1 @@ -1,350 +1,542 @@ -$logprefix = Get-Date -Format "ddMMyyyy_HHmmss" -if ($PSScriptRoot.length -eq 0) { - $scriptroot = Get-Location -} -else { - $scriptroot = $PSScriptRoot -} -$RelativeLogdir = Join-Path -Path $scriptroot -ChildPath "..\logs" -$logDir = (Resolve-Path -Path $RelativeLogdir).Path - -Start-Transcript -Path "$logDir/report_new_matches_$logprefix.log" -Append -. $scriptroot\..\includes\ps1\lockfile.ps1 -new-lock -by "report_new_matches" -write-output "Scriptroot: $scriptroot" -write-output "Scriptname: $($MyInvocation.MyCommand)" -write-output "Script: $($MyInvocation.MyCommand.Path)" -write-output "PSScriptroot: $PSScriptRoot" -write-output "Logdir: $logDir" - -$fileContent = Get-Content -Path "$scriptroot/../config/config.php" -Raw - -# Use regex to match the apiKey value -if ($fileContent -match "\`$apiKey\s*=\s*\'([^\']+)\'") { - $apiKey = $matches[1] -} -else { - Write-Output "API Key not found" -} - -$headers = @{ - 'accept' = 'application/vnd.api+json' - 'Authorization' = "$apiKey" -} - - -$fileContent = Get-Content -Path "$scriptroot/../discord/config.php" -Raw - -# Use regex to match the apiKey value -if ($fileContent -match "\`$webhookurl\s*=\s*'([^']+)'") { - $webhookurl = $matches[1] +# --- Script Setup --- +$logPrefix = Get-Date -Format "yyyyMMdd_HHmmss" # Use standard sortable format +# Determine script root directory reliably +if ($PSScriptRoot) { + $scriptRoot = $PSScriptRoot } else { - Write-Output "No web url found" + $scriptRoot = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition + Write-Warning "PSScriptRoot not defined, using calculated path: $scriptRoot" } +Write-Output "Script root identified as: $scriptRoot" -# Use regex to match the losers webhook url -if ($fileContent -match "\`$webhookurl_losers\s*=\s*'([^']+)'") { - $webhookurl_losers = $matches[1] -} else { - Write-Output "No losers web url found" -} +# Define paths using Join-Path +$includesPath = Join-Path -Path $scriptRoot -ChildPath "..\includes\ps1" +$configPath = Join-Path -Path $scriptRoot -ChildPath "..\config" +$discordConfigPath = Join-Path -Path $scriptRoot -ChildPath "config.php" # Config is in the same dir +$dataPath = Join-Path -Path $scriptRoot -ChildPath "..\data" +$logDir = Join-Path -Path $scriptRoot -ChildPath "..\logs" # Assuming logs dir relative to scriptroot parent +$playerMatchesJsonPath = Join-Path -Path $dataPath -ChildPath "player_matches.json" -function send-discord { - param ( - $content - ) - $payload = [PSCustomObject]@{ - - content = $content - - } - - Invoke-RestMethod -Uri $webhookurl -Method Post -Body ($payload | ConvertTo-Json) -ContentType 'Application/Json' -} - -function send-discord-losers { - param ( - $content - ) - $payload = [PSCustomObject]@{ - content = $content - } - Invoke-RestMethod -Uri $webhookurl_losers -Method Post -Body ($payload | ConvertTo-Json) -ContentType 'Application/Json' -} - -$map_map = @{ - "Baltic_Main" = "Erangel" - "Chimera_Main" = "Paramo" - "Desert_Main" = "Miramar" - "DihorOtok_Main" = "Vikendi" - "Erangel_Main" = "Erangel" - "Heaven_Main" = "Haven" - "Kiki_Main" = "Deston" - "Range_Main" = "Camp Jackal" - "Savage_Main" = "Sanhok" - "Summerland_Main" = "Karakin" - "Tiger_Main" = "Taego" - "Neon_Main" = "Rondo" -} - -try { - $player_matches = get-content "$scriptroot/../data/player_matches.json" | convertfrom-json -Depth 100 -} -catch { - Write-Output 'Unable to read file exitin' -} -Write-Output $player_matches -Write-Output $new_win_matches -$new_win_matches = $player_matches[-1].new_win_matches - -# Gebruik nu de lijst van nieuwe verloren matches uit het JSON-bestand -$new_loss_matches = $player_matches[-1].new_loss_matches - -# Post verloren matches naar #losers kanaal -foreach ($lossid in $new_loss_matches) { - $lossmatch = $player_matches.player_matches | Where-Object { $_.id -eq $lossid } - if ($null -eq $lossmatch) { continue } - if ($lossmatch[0].gameMode -eq 'tdm') { continue } - - # Fetch detailed match stats and telemetry for the loss - $loss_match_stats = $null - $loss_telemetry = $null +# Ensure Log directory exists +if (-not (Test-Path -Path $logDir -PathType Container)) { + Write-Warning "Log directory not found at '$logDir'. Attempting to create." try { - $loss_match_stats = Invoke-RestMethod -Uri "https://api.pubg.com/shards/steam/matches/$lossid" -Method GET -Headers $headers - $loss_telemetry = (invoke-webrequest @($lossmatch.telemetry_url)[0]).content | convertfrom-json | where-object { ($_._T -eq 'LOGPLAYERTAKEDAMAGE') -or ($_._T -eq 'LOGPLAYERKILLV2') } + New-Item -Path $logDir -ItemType Directory -Force -ErrorAction Stop | Out-Null + Write-Output "Successfully created log directory." } catch { - $errorMessage = $_.Exception.Message - Write-Warning ("Failed to fetch API/telemetry data for loss match {0}: {1}" -f $lossid, $errorMessage) + # Log to console if transcript path fails + Write-Error "Failed to create log directory '$logDir'. Please check permissions. Error: $($_.Exception.Message)" + # Continue without transcript if log dir fails? Or exit? For now, continue. } - - $loss_stats_table = @() - $loss_victims = @() # For team damage - - # Iterate through players found in the locally stored match data for this loss - foreach ($player_stat in $lossmatch[0].stats) { - $player_name = $player_stat.name - # Find the corresponding detailed stats from the API response - $detailed_player_stats = $null - if ($null -ne $loss_match_stats) { - $detailed_player_stats = $loss_match_stats.included | Where-Object {$_.type -eq 'participant'} | ForEach-Object {$_.attributes.stats} | Where-Object { $_.name -eq $player_name } - } - - if ($null -eq $detailed_player_stats) { - Write-Warning "Could not find detailed stats for player $player_name in loss match $lossid. Using basic stats." - # Fallback to basic stats if detailed stats are missing - $loss_stats_table += [PSCustomObject]@{ - Name = $player_name - 'Human dmg' = "N/A" - 'Human Kills' = "N/A" - 'Dmg' = "$([math]::Round($player_stat.damageDealt))" # Use basic stat - 'Kills' = "$($player_stat.kills)" # Use basic stat - 'alive' = "$([math]::Round(($player_stat.timeSurvived / 60)))" # Use basic stat - } - continue # Skip telemetry processing if detailed stats failed - } - - # Calculate stats (similar to win stats calculation) - $human_dmg = "N/A" - $human_kills = "N/A" - if ($null -ne $loss_telemetry) { - try { - $human_dmg = [math]::Round(($loss_telemetry | Where-Object { $_._T -eq 'LOGPLAYERTAKEDAMAGE' -and $_.attacker.name -eq $player_name -and $_.victim.accountId -notlike "ai.*" -and $_.victim.teamId -ne $_.attacker.teamId } | Measure-Object -Property damage -Sum).Sum) - $human_kills = ($loss_telemetry | Where-Object { $_._T -eq 'LOGPLAYERKILLV2' -and $_.killer.name -eq $player_name -and $_.victim.accountId -notlike "ai.*" }).count - } catch { - $errorMessage = $_.Exception.Message - Write-Warning ("Error processing telemetry stats for {0} in loss {1}: {2}" -f $player_name, $lossid, $errorMessage) - } - } - - $loss_stats_table += [PSCustomObject]@{ - Name = $player_name - 'Human dmg' = "$human_dmg" - 'Human Kills' = "$human_kills" - 'Dmg' = "$([math]::Round($detailed_player_stats.damageDealt))" - 'Kills' = "$($detailed_player_stats.kills)" - 'alive' = "$([math]::Round(($detailed_player_stats.timeSurvived / 60)))" - } - - # Calculate team damage - if ($null -ne $loss_telemetry) { - try { - $teamdmg = $loss_telemetry | Where-Object { - $_._T -eq 'LOGPLAYERTAKEDAMAGE' -and - $_.victim.teamId -eq $_.attacker.teamId -and - $_.victim.accountId -notlike "ai.*" -and - $_.victim.name -ne $_.attacker.name -and - $_.attacker.name -eq $player_name - } - if ($teamdmg.count -ge 1) { - foreach ($victim_name in ($teamdmg.victim.name | Select-Object -Unique)) { - $loss_victims += [PSCustomObject]@{ - attacker = $player_name - victim = $victim_name - Damage = "$([math]::Round((($teamdmg | Where-Object { $_.victim.name -eq $victim_name }).damage | Measure-Object -Sum).Sum))" - } - } - } - } catch { - $errorMessage = $_.Exception.Message - Write-Warning ("Error processing team damage for {0} in loss {1}: {2}" -f $player_name, $lossid, $errorMessage) - } - } - } - - # Format the stats table - $content_lossstats = "" - if ($loss_stats_table.Count -gt 0) { - $content_lossstats = '```' + ($loss_stats_table | Format-Table -AutoSize | Out-String) + '```' - } - - # Format team damage table - $content_loss_victims = "" - if ($loss_victims.Count -gt 0) { - $content_loss_victims = ":skull::skull: Team Damage :skull::skull:`n" + '```' + ($loss_victims | Format-Table -AutoSize | Out-String) + '```' - } - - # Original message construction variables - $losers = $lossmatch[0].stats.name -join ', ' # Join names for display - $map = $map_map[$lossmatch[0].mapName] - $place = ($lossmatch[0].stats | Select-Object -First 1).winPlace # Get placement from the first player stat - $first_player_name = ($lossmatch[0].stats | Select-Object -First 1).name - $replay_url = $lossmatch[0].telemetry_url -replace 'https://telemetry-cdn.pubg.com/bluehole-pubg', 'https://chickendinner.gg' - $replay_url = $replay_url -replace '-telemetry.json', '' - $replay_url = $replay_url + "?follow=$first_player_name" # Follow the first player - $match_settings = @" -`````` -match mode $($lossmatch[0].gameMode) -match type $($lossmatch[0].matchType) -map $($map_map[$lossmatch[0].mapName]) -id $($lossmatch[0].id) -`````` -"@ - send-discord-losers -content "We hebben een LOSERT! Geen Kip voor jou! :skull::skull:" - send-discord-losers -content ":partying_face::partying_face::partying_face: Helaas, $($losers) :partying_face::partying_face::partying_face:" - send-discord-losers -content $match_settings - send-discord-losers -content $content_lossstats - send-discord-losers -content $content_loss_victims - send-discord-losers -content "[2D replay](<$replay_url>)" - send-discord-losers -content "Meer match details [DTCH_STATS]()" } +# Start Transcript (use calculated $logDir) +$transcriptPath = Join-Path -Path $logDir -ChildPath "report_new_matches_$logPrefix.log" +try { + Start-Transcript -Path $transcriptPath -Append -ErrorAction Stop + Write-Output "Starting report_new_matches script at $(Get-Date)" + Write-Output "Running from: $(Get-Location)" + Write-Output "Transcript logging to: $transcriptPath" +} catch { + Write-Error "Failed to start transcript at '$transcriptPath'. Error: $($_.Exception.Message)" + # Exit if transcript is critical? For now, continue. +} -foreach ($winid in $new_win_matches) { +# --- Locking --- +$lockFilePath = Join-Path -Path $includesPath -ChildPath "lockfile.ps1" +if (-not (Test-Path -Path $lockFilePath -PathType Leaf)) { + Write-Error "Lockfile script not found at '$lockFilePath'. Cannot proceed." + if ($transcriptPath) { Stop-Transcript } + exit 1 +} +. $lockFilePath +New-Lock -by "report_new_matches" -ErrorAction Stop # Stop if locking fails - $win_stats = @() - $victims = @() - if ($null -eq $winid) { continue } - $winmatches = $player_matches.player_matches | Where-Object { $_.id -eq $winid } - $telemetry = (invoke-webrequest @($winmatches.telemetry_url)[0]).content | convertfrom-json | where-object { ($_._T -eq 'LOGPLAYERTAKEDAMAGE') -or ($_._T -eq 'LOGPLAYERKILLV2') } - $winners = @(($winmatches | where-object { $_.stats.winPlace -eq 1 }).stats.name) - $2D_replay_url = @($winmatches.telemetry_url)[0] -replace 'https://telemetry-cdn.pubg.com/bluehole-pubg', 'https://chickendinner.gg' - $2D_replay_url = $2D_replay_url -replace '-telemetry.json', '' - $2D_replay_url = $2D_replay_url + "?follow=$($winners[0])" +# --- Main Logic in Try/Finally for Lock Removal --- +try { + # --- Configuration Loading --- + $apiKey = $null + $webhookUrl = $null + $webhookUrlLosers = $null - $match_stats = Invoke-RestMethod -Uri "https://api.pubg.com/shards/steam/matches/$winid" -Method GET -Headers $headers - if ($winmatches[0].gameMode -eq 'tdm' ) { - continue - } #skip tdm matches - if ($winmatches[0].matchType -eq 'custom') { - $players_to_report = $match_stats.included.attributes.stats | where-object { $_.playerId -notlike "ai.*" } + # Load API Key from main config.php + $phpConfigPath = Join-Path -Path $configPath -ChildPath "config.php" + if (Test-Path -Path $phpConfigPath -PathType Leaf) { + try { + $fileContent = Get-Content -Path $phpConfigPath -Raw -ErrorAction Stop + if ($fileContent -match '^\s*\$apiKey\s*=\s*''([^'']+)''') { $apiKey = $matches[1]; Write-Verbose "API Key loaded." } + else { Write-Warning "API Key pattern not found in '$phpConfigPath'." } + } catch { Write-Warning "Failed to read '$phpConfigPath': $($_.Exception.Message)" } + } else { Write-Warning "Main config file not found at '$phpConfigPath'." } + + # Load Webhook URLs from discord/config.php + if (Test-Path -Path $discordConfigPath -PathType Leaf) { + try { + $discordFileContent = Get-Content -Path $discordConfigPath -Raw -ErrorAction Stop + if ($discordFileContent -match '^\s*\$webhookurl\s*=\s*''([^'']+)''') { $webhookUrl = $matches[1]; Write-Verbose "Main webhook URL loaded." } + else { Write-Warning "Main webhook URL pattern not found in '$discordConfigPath'." } + + if ($discordFileContent -match '^\s*\$webhookurl_losers\s*=\s*''([^'']+)''') { $webhookUrlLosers = $matches[1]; Write-Verbose "Losers webhook URL loaded." } + else { Write-Warning "Losers webhook URL pattern not found in '$discordConfigPath'." } + } catch { Write-Warning "Failed to read '$discordConfigPath': $($_.Exception.Message)" } + } else { Write-Warning "Discord config file not found at '$discordConfigPath'." } + + # Validate required config + if (-not $apiKey) { Write-Error "API Key missing."; throw "Missing API Key" } + if (-not $webhookUrl) { Write-Error "Main Discord webhook URL missing."; throw "Missing Webhook URL" } + if (-not $webhookUrlLosers) { Write-Error "Losers Discord webhook URL missing."; throw "Missing Losers Webhook URL" } + + # --- API Headers --- + $apiHeaders = @{ + 'accept' = 'application/vnd.api+json' + 'Authorization' = "Bearer $apiKey" } - else { - $players_to_report = $match_stats.included.attributes.stats | where-object { $_.winplace -eq 1 } + + # --- Helper Function for API Calls (Copied from update_clan_members.ps1) --- + function Invoke-PubgApi { + param( + [Parameter(Mandatory=$true)][string]$Uri, [Parameter(Mandatory=$true)][hashtable]$Headers, + [int]$RetryCount = 1, [int]$RetryDelaySeconds = 61 + ) + for ($attempt = 1; $attempt -le ($RetryCount + 1); $attempt++) { + try { + Write-Verbose "API call (Attempt $($attempt)): $Uri" + $response = Invoke-RestMethod -Uri $Uri -Method GET -Headers $Headers -ErrorAction Stop + if ($null -ne $response) { Write-Verbose "API call successful."; return $response } + else { Write-Warning "API call to $Uri returned null."; return $null } + } catch { + $statusCode = $_.Exception.Response.StatusCode.value__ + $errorMessage = $_.Exception.Message + Write-Warning "API call failed (Attempt $($attempt)). Status: $statusCode. Error: $errorMessage" + if ($attempt -le $RetryCount -and $statusCode -eq 429) { + Write-Warning "Rate limit hit. Sleeping $RetryDelaySeconds sec..."; Start-Sleep -Seconds $RetryDelaySeconds + } elseif ($attempt -gt $RetryCount) { Write-Error "API call failed after $($attempt) attempts. URI: $Uri. Last Error: $errorMessage"; return $null } + else { Write-Error "Non-retryable API error. URI: $Uri. Error: $errorMessage"; return $null } + } + } + return $null } - if ($new_win_matches.count -le 10) { - #fail safe - send-discord -content ":chicken: :chicken: **WINNER WINNER CHICKEN DINNER!!** :chicken: :chicken:" - send-discord -content ":partying_face::partying_face::partying_face: Gefeliciteerd $($winners -join ', ') :partying_face::partying_face::partying_face:" - $match_settings = @" -`````` -match mode $($winmatches[0].gameMode) -match type $($winmatches[0].matchType) -map $($map_map[$winmatches[0].mapName]) -id $($winmatches[0].id) -`````` -"@ - send-discord -content $match_settings + # --- Helper Function for Telemetry Download/Parse --- + function Get-TelemetryData { + param([string]$TelemetryUrl) + if (-not $TelemetryUrl) { Write-Warning "Get-TelemetryData: No Telemetry URL provided."; return $null } + + $telemetryFileName = $TelemetryUrl.Split('/')[-1] + $telemetryCacheFilePath = Join-Path -Path $telemetryCachePath -ChildPath $telemetryFileName + + # Try cache first + if (Test-Path -Path $telemetryCacheFilePath -PathType Leaf) { + Write-Verbose "Loading telemetry from cache: $telemetryCacheFilePath" + try { + $telemetry = Get-Content -Path $telemetryCacheFilePath | ConvertFrom-Json -ErrorAction Stop + if ($null -ne $telemetry) { return $telemetry } + else { Write-Warning "Failed to parse cached telemetry: $telemetryCacheFilePath" } + } catch { Write-Warning "Error reading cached telemetry '$telemetryCacheFilePath': $($_.Exception.Message)" } + } + + # Download if not cached or cache failed + Write-Output "Downloading telemetry: $TelemetryUrl" + try { + $webHeaders = @{ 'Accept-Encoding' = 'gzip' } + $response = Invoke-WebRequest -Uri $TelemetryUrl -Headers $webHeaders -UseBasicParsing -ErrorAction Stop + $telemetryContent = $response.Content + $telemetryContent | Out-File -FilePath $telemetryCacheFilePath -Encoding UTF8 -ErrorAction SilentlyContinue + $telemetry = $telemetryContent | ConvertFrom-Json -ErrorAction Stop + if ($null -eq $telemetry) { Write-Warning "Failed to parse downloaded telemetry from $TelemetryUrl." } + return $telemetry + } catch { + $errorMessage = $_.Exception.Message + Write-Warning "Failed to download/save telemetry from $TelemetryUrl. Error: $errorMessage" + return $null + } } - else { - write-output "Something went wrong (more then 10 matches to report)" - } - foreach ($player in $players_to_report.name) { - if ($null -eq $player) { continue } - write-output "creating table for player $player" - $win_stats += [PSCustomObject]@{ - Name = $player - 'Human dmg' = "$([math]::Round(($telemetry | Where-Object { $_._T -eq 'LOGPLAYERTAKEDAMAGE' -and $_.attacker.name -eq $player -and $_.victim.accountId -notlike "ai.*" -and $_.victim.teamId -ne $_.attacker.teamId } | Measure-Object -Property damage -Sum).Sum))" - 'Human Kills' = "$(($telemetry | Where-Object { $_._T -eq 'LOGPLAYERKILLV2' -and $_.killer.name -eq $player -and $_.victim.accountId -notlike "ai.*" }).count)" - 'Dmg' = "$([math]::Round(($players_to_report | Where-Object { $_.name -eq $player }).damageDealt))" - 'Kills' = "$(($players_to_report | Where-Object { $_.name -eq $player }).kills)" - 'alive' = "$([math]::Round((($players_to_report | Where-Object { $_.name -eq $player }).timeSurvived /60 )))" + + # --- Discord Sending Functions (with Error Handling) --- + function Send-DiscordMessage { + param([string]$Webhook, [string]$Content) + if (-not $Webhook -or -not $Content) { Write-Warning "Send-DiscordMessage: Missing Webhook or Content."; return } + + $payload = @{ content = $Content } | ConvertTo-Json -Depth 3 + try { + Invoke-RestMethod -Uri $Webhook -Method Post -Body $payload -ContentType 'application/json' -ErrorAction Stop + Write-Verbose "Successfully sent message to Discord." + } catch { + $errorMessage = $_.Exception.Message + Write-Error "Failed to send message to Discord ($Webhook). Error: $errorMessage" } - $teamdmg = $telemetry | Where-Object { - $_._T -eq 'LOGPLAYERTAKEDAMAGE' -and - $_.victim.teamId -eq $_.attacker.teamId -and - $_.victim.accountId -notlike "ai.*" -and - $_.victim.name -ne $_.attacker.name -and - $_.attacker.name -eq $player + } + function Send-DiscordWin($Content) { Send-DiscordMessage -Webhook $webhookUrl -Content $Content } + function Send-DiscordLoss($Content) { Send-DiscordMessage -Webhook $webhookUrlLosers -Content $Content } + + # --- Map Definitions --- + $mapNameLookup = @{ # Renamed from $map_map + "Baltic_Main" = "Erangel" + "Chimera_Main" = "Paramo" + "Desert_Main" = "Miramar" + "DihorOtok_Main" = "Vikendi" + "Erangel_Main" = "Erangel" # Duplicate key, might be intentional? + "Heaven_Main" = "Haven" + "Kiki_Main" = "Deston" + "Range_Main" = "Camp Jackal" + "Savage_Main" = "Sanhok" + "Summerland_Main" = "Karakin" + "Tiger_Main" = "Taego" + "Neon_Main" = "Rondo" + } + + # --- Load Player Matches Data --- + $playerMatchesData = $null + if (Test-Path -Path $playerMatchesJsonPath -PathType Leaf) { + try { + $playerMatchesData = Get-Content -Path $playerMatchesJsonPath | ConvertFrom-Json -Depth 100 -ErrorAction Stop + if ($null -eq $playerMatchesData -or -not ($playerMatchesData -is [array])) { + Write-Error "Invalid structure in '$playerMatchesJsonPath'. Expected array. Cannot proceed." + throw "Invalid player matches data." + } + Write-Output "Successfully loaded player matches data." + } catch { + Write-Error "Error reading '$playerMatchesJsonPath': $($_.Exception.Message). Cannot proceed." + throw $_ + } + } else { + Write-Error "Player matches file not found at '$playerMatchesJsonPath'. Cannot proceed." + throw "Missing player matches file." + } + + # --- Extract New Wins and Losses --- + # Find the special entries added by get_matches.ps1 + $newWinEntry = $playerMatchesData | Where-Object { $_.PSObject.Properties.Name -eq 'new_win_matches' } | Select-Object -First 1 + $newLossEntry = $playerMatchesData | Where-Object { $_.PSObject.Properties.Name -eq 'new_loss_matches' } | Select-Object -First 1 + + $newWinMatchIds = if ($null -ne $newWinEntry -and $newWinEntry.new_win_matches -is [array]) { $newWinEntry.new_win_matches } else { @() } + $newLossMatchIds = if ($null -ne $newLossEntry -and $newLossEntry.new_loss_matches -is [array]) { $newLossEntry.new_loss_matches } else { @() } + + Write-Output "Found $($newWinMatchIds.Count) new win match IDs and $($newLossMatchIds.Count) new loss match IDs to report." + + # Extract actual match data (excluding the special entries) + $actualMatchEntries = $playerMatchesData | Where-Object { $_.PSObject.Properties.Name -ne 'new_win_matches' -and $_.PSObject.Properties.Name -ne 'new_loss_matches' } + + # --- Process and Report New Losses --- + Write-Output "Processing $($newLossMatchIds.Count) new losses..." + foreach ($lossId in $newLossMatchIds) { + if (-not $lossId) { continue } + Write-Output "Processing loss match ID: $lossId" + + # Find all player entries related to this loss match ID in the loaded data + $lossMatchPlayerEntries = $actualMatchEntries.player_matches | Where-Object { $_.id -eq $lossId } + if ($null -eq $lossMatchPlayerEntries -or $lossMatchPlayerEntries.Count -eq 0) { + Write-Warning "Could not find match details for loss ID $lossId in player_matches.json data." + continue } - if ($teamdmg.count -ge 1) { - foreach ($victim in ($teamdmg.victim.name | Select-Object -Unique)) { - $victims += [PSCustomObject]@{ - attacker = $player - victim = $victim - Damage = "$([math]::Round((($teamdmg | Where-Object { $_.victim.name -eq $victim }).damage | Measure-Object -Sum).Sum))" - } + # Use the first entry for common match details (assuming they are consistent) + $firstLossEntry = $lossMatchPlayerEntries[0] + $lossGameMode = $firstLossEntry.gameMode + $lossMatchType = $firstLossEntry.matchType + $lossMapNameRaw = $firstLossEntry.mapName + $lossTelemetryUrl = $firstLossEntry.telemetry_url + + # Skip TDM matches + if ($lossGameMode -eq 'tdm') { Write-Output "Skipping TDM loss match $lossId."; continue } + + # Fetch Full Match Stats from API (needed for all participants' details) + $lossMatchApiStats = Invoke-PubgApi -Uri "https://api.pubg.com/shards/steam/matches/$lossId" -Headers $apiHeaders + + # Get Telemetry Data + $lossTelemetryEvents = Get-TelemetryData -TelemetryUrl $lossTelemetryUrl + $relevantLossTelemetry = $null + if ($lossTelemetryEvents -is [array]) { + $relevantLossTelemetry = $lossTelemetryEvents | Where-Object { $_._T -eq 'LogPlayerTakeDamage' -or $_._T -eq 'LogPlayerKillV2' } + } else { Write-Warning "Invalid or missing telemetry data for loss match $lossId." } + + # Prepare data tables + $lossStatsTable = @() + $lossTeamDamageVictims = @() + + # Iterate through players involved in this loss from our data + $involvedPlayerNames = $lossMatchPlayerEntries.stats.name | Select-Object -Unique + foreach ($playerName in $involvedPlayerNames) { + $playerLossStats = $lossMatchPlayerEntries | Where-Object { $_.stats.name -eq $playerName } | Select-Object -First 1 -ExpandProperty stats + + # Try to get more detailed stats from the full API response if available + $detailedPlayerStats = $null + if ($null -ne $lossMatchApiStats -and $lossMatchApiStats.included -is [array]) { + $detailedPlayerStats = $lossMatchApiStats.included | + Where-Object { $_.type -eq 'participant' -and $_.attributes.stats.name -eq $playerName } | + Select-Object -First 1 -ExpandProperty attributes | Select-Object -ExpandProperty stats + } + + # Use detailed stats if found, otherwise fallback to basic stats from player_matches.json + $statsToUse = if ($null -ne $detailedPlayerStats) { $detailedPlayerStats } else { $playerLossStats } + + if ($null -eq $statsToUse) { Write-Warning "Could not find any stats for $playerName in loss $lossId."; continue } + + # Calculate Human Kills/Damage from Telemetry + $humanDmg = "N/A" + $humanKills = "N/A" + if ($null -ne $relevantLossTelemetry) { + try { + $humanDmgEvents = $relevantLossTelemetry | Where-Object { $_._T -eq 'LogPlayerTakeDamage' -and $_.attacker.name -eq $playerName -and $_.victim.accountId -notlike "ai.*" -and $_.victim.teamId -ne $_.attacker.teamId } + if ($humanDmgEvents) { $humanDmg = [math]::Round(($humanDmgEvents | Measure-Object -Property damage -Sum).Sum) } + + $humanKillEvents = $relevantLossTelemetry | Where-Object { $_._T -eq 'LogPlayerKillV2' -and $_.killer.name -eq $playerName -and $_.victim.accountId -notlike "ai.*" } + $humanKills = $humanKillEvents.Count + } catch { Write-Warning ("Error processing telemetry stats for {0} in loss {1}: {2}" -f $playerName, $lossId, $_.Exception.Message) } + } + + # Add to stats table + $lossStatsTable += [PSCustomObject]@{ + Name = $playerName + 'Human dmg' = "$humanDmg" + 'Human Kills' = "$humanKills" + 'Dmg' = "$([math]::Round($statsToUse.damageDealt))" + 'Kills' = "$($statsToUse.kills)" + 'alive (min)' = "$([math]::Round(($statsToUse.timeSurvived / 60)))" # Clarify unit + } + + # Calculate team damage from Telemetry + if ($null -ne $relevantLossTelemetry) { + try { + $teamDmgEvents = $relevantLossTelemetry | Where-Object { + $_._T -eq 'LogPlayerTakeDamage' -and $_.victim.teamId -eq $_.attacker.teamId -and + $_.victim.accountId -notlike "ai.*" -and $_.victim.name -ne $_.attacker.name -and + $_.attacker.name -eq $playerName + } + if ($teamDmgEvents) { + foreach ($victimEntry in ($teamDmgEvents | Group-Object victim.name)) { + $lossTeamDamageVictims += [PSCustomObject]@{ + attacker = $playerName + victim = $victimEntry.Name + Damage = "$([math]::Round(($victimEntry.Group.damage | Measure-Object -Sum).Sum))" + } + } + } + } catch { Write-Warning ("Error processing team damage for {0} in loss {1}: {2}" -f $playerName, $lossId, $_.Exception.Message) } + } + } # End foreach playerName in loss + + # Format tables for Discord message + $contentLossStats = if ($lossStatsTable.Count -gt 0) { '```' + ($lossStatsTable | Format-Table -AutoSize | Out-String) + '```' } else { "" } + $contentLossVictims = if ($lossTeamDamageVictims.Count -gt 0) { ":skull::skull: Team Damage :skull::skull:`n" + '```' + ($lossTeamDamageVictims | Format-Table -AutoSize | Out-String) + '```' } else { "" } + + # Construct and Send Loss Message + $losersString = $involvedPlayerNames -join ', ' + $mapDisplayName = if ($mapNameLookup.ContainsKey($lossMapNameRaw)) { $mapNameLookup[$lossMapNameRaw] } else { $lossMapNameRaw } + $firstPlayerName = $involvedPlayerNames[0] # Use first player for replay link + $replayUrl = $lossTelemetryUrl -replace 'https://telemetry-cdn.pubg.com/bluehole-pubg', 'https://chickendinner.gg' -replace '-telemetry.json', '' + $replayUrl += "?follow=$firstPlayerName" + + $matchSettings = @" +`````` +Match Mode : $lossGameMode +Match Type : $lossMatchType +Map : $mapDisplayName +Match ID : $lossId +`````` +"@ + Send-DiscordLoss -Content "We hebben een LOSERT! Geen Kip voor jou! :skull::skull:" + Send-DiscordLoss -Content ":partying_face::partying_face: Helaas, **$($losersString)** :partying_face::partying_face:" + Send-DiscordLoss -Content $matchSettings + if ($contentLossStats) { Send-DiscordLoss -Content $contentLossStats } + if ($contentLossVictims) { Send-DiscordLoss -Content $contentLossVictims } + Send-DiscordLoss -Content "[2D Replay](<$replayUrl>)" + Send-DiscordLoss -Content "Meer match details [DTCH_STATS]()" + + Write-Output "Sent loss report for match $lossId." + Start-Sleep -Seconds 2 # Small delay between messages + + } # End foreach lossId + + # --- Process and Report New Wins --- + Write-Output "Processing $($newWinMatchIds.Count) new wins..." + foreach ($winId in $newWinMatchIds) { + if (-not $winId) { continue } + Write-Output "Processing win match ID: $winId" + + # Find all player entries related to this win match ID + $winMatchPlayerEntries = $actualMatchEntries.player_matches | Where-Object { $_.id -eq $winId } + if ($null -eq $winMatchPlayerEntries -or $winMatchPlayerEntries.Count -eq 0) { + Write-Warning "Could not find match details for win ID $winId in player_matches.json data." + continue + } + + # Use the first entry for common details + $firstWinEntry = $winMatchPlayerEntries[0] + $winGameMode = $firstWinEntry.gameMode + $winMatchType = $firstWinEntry.matchType + $winMapNameRaw = $firstWinEntry.mapName + $winTelemetryUrl = $firstWinEntry.telemetry_url + + # Skip TDM + if ($winGameMode -eq 'tdm') { Write-Output "Skipping TDM win match $winId."; continue } + + # Get Telemetry + $winTelemetryEvents = Get-TelemetryData -TelemetryUrl $winTelemetryUrl + $relevantWinTelemetry = $null + if ($winTelemetryEvents -is [array]) { + $relevantWinTelemetry = $winTelemetryEvents | Where-Object { $_._T -eq 'LogPlayerTakeDamage' -or $_._T -eq 'LogPlayerKillV2' } + } else { Write-Warning "Invalid or missing telemetry data for win match $winId." } + + # Get Full Match Stats from API + $winMatchApiStats = Invoke-PubgApi -Uri "https://api.pubg.com/shards/steam/matches/$winId" -Headers $apiHeaders + + # Determine players to report (winners or all non-AI in custom) + $playersToReportStats = @() + if ($null -ne $winMatchApiStats -and $winMatchApiStats.included -is [array]) { + if ($winMatchType -eq 'custom') { + $playersToReportStats = $winMatchApiStats.included | Where-Object { $_.type -eq 'participant' -and $_.attributes.stats.playerId -notlike "ai.*" } | Select-Object -ExpandProperty attributes | Select-Object -ExpandProperty stats + } else { + $playersToReportStats = $winMatchApiStats.included | Where-Object { $_.type -eq 'participant' -and $_.attributes.stats.winPlace -eq 1 } | Select-Object -ExpandProperty attributes | Select-Object -ExpandProperty stats + } + } else { + Write-Warning "Could not get full match stats from API for win $winId. Reporting might be incomplete." + # Fallback: Use players from our loaded data who won + $playersToReportStats = $winMatchPlayerEntries | Where-Object { $_.stats.winPlace -eq 1 } | Select-Object -ExpandProperty stats + } + + if ($playersToReportStats.Count -eq 0) { Write-Warning "No winning players found to report for match $winId."; continue } + + $winnerNames = $playersToReportStats.name + $winnersString = $winnerNames -join ', ' + + # Prepare data tables + $winStatsTable = @() + $winTeamDamageVictims = @() + + # Fail-safe check (from original script) - Limit number of reports if too many new wins detected at once? + # This might indicate an issue with the comparison logic or first run. + if ($newWinMatchIds.Count -gt 10) { + Write-Warning "More than 10 new win matches detected ($($newWinMatchIds.Count)). This might indicate an issue. Reporting only the first 10." + # Optionally break or limit the loop here if desired + # For now, just log the warning. + } + + # Send initial win messages + Send-DiscordWin -Content ":chicken::chicken: **WINNER WINNER CHICKEN DINNER!!** :chicken::chicken:" + Send-DiscordWin -Content ":partying_face::partying_face: Gefeliciteerd **$($winnersString)** :partying_face::partying_face:" + + $mapDisplayName = if ($mapNameLookup.ContainsKey($winMapNameRaw)) { $mapNameLookup[$winMapNameRaw] } else { $winMapNameRaw } + $matchSettings = @" +`````` +Match Mode : $winGameMode +Match Type : $winMatchType +Map : $mapDisplayName +Match ID : $winId +`````` +"@ + Send-DiscordWin -Content $matchSettings + + # Calculate stats for each reported player + foreach ($playerStat in $playersToReportStats) { + $playerName = $playerStat.name + if (-not $playerName) { continue } + + Write-Verbose "Creating stats table entry for winner $playerName in match $winId" + + # Calculate Human Kills/Damage from Telemetry + $humanDmg = "N/A" + $humanKills = "N/A" + if ($null -ne $relevantWinTelemetry) { + try { + $humanDmgEvents = $relevantWinTelemetry | Where-Object { $_._T -eq 'LogPlayerTakeDamage' -and $_.attacker.name -eq $playerName -and $_.victim.accountId -notlike "ai.*" -and $_.victim.teamId -ne $_.attacker.teamId } + if ($humanDmgEvents) { $humanDmg = [math]::Round(($humanDmgEvents | Measure-Object -Property damage -Sum).Sum) } + + $humanKillEvents = $relevantWinTelemetry | Where-Object { $_._T -eq 'LogPlayerKillV2' -and $_.killer.name -eq $playerName -and $_.victim.accountId -notlike "ai.*" } + $humanKills = $humanKillEvents.Count + } catch { Write-Warning ("Error processing telemetry stats for {0} in win {1}: {2}" -f $playerName, $winId, $_.Exception.Message) } + } + + # Add to stats table + $winStatsTable += [PSCustomObject]@{ + Name = $playerName + 'Human dmg' = "$humanDmg" + 'Human Kills' = "$humanKills" + 'Dmg' = "$([math]::Round($playerStat.damageDealt))" + 'Kills' = "$($playerStat.kills)" + 'alive (min)' = "$([math]::Round(($playerStat.timeSurvived / 60)))" + } + + # Calculate team damage from Telemetry + if ($null -ne $relevantWinTelemetry) { + try { + $teamDmgEvents = $relevantWinTelemetry | Where-Object { + $_._T -eq 'LogPlayerTakeDamage' -and $_.victim.teamId -eq $_.attacker.teamId -and + $_.victim.accountId -notlike "ai.*" -and $_.victim.name -ne $_.attacker.name -and + $_.attacker.name -eq $playerName + } + if ($teamDmgEvents) { + foreach ($victimEntry in ($teamDmgEvents | Group-Object victim.name)) { + $winTeamDamageVictims += [PSCustomObject]@{ + attacker = $playerName + victim = $victimEntry.Name + Damage = "$([math]::Round(($victimEntry.Group.damage | Measure-Object -Sum).Sum))" + } + } + } + } catch { Write-Warning ("Error processing team damage for {0} in win {1}: {2}" -f $playerName, $winId, $_.Exception.Message) } + } + } # End foreach playerStat + + # Format and send stats tables + $contentWinStats = if ($winStatsTable.Count -gt 0) { '```' + ($winStatsTable | Format-Table -AutoSize | Out-String) + '```' } else { "" } + if ($contentWinStats) { Send-DiscordWin -Content $contentWinStats } + + $contentWinVictims = if ($winTeamDamageVictims.Count -gt 0) { ":skull::skull: Team Damage Report :skull::skull:`n" + '```' + ($winTeamDamageVictims | Format-Table -AutoSize | Out-String) + '```' } else { "" } + if ($contentWinVictims) { Send-DiscordWin -Content $contentWinVictims } + + # Send Replay and Details Links + $firstWinnerName = $winnerNames[0] + $replayUrl = $winTelemetryUrl -replace 'https://telemetry-cdn.pubg.com/bluehole-pubg', 'https://chickendinner.gg' -replace '-telemetry.json', '' + $replayUrl += "?follow=$firstWinnerName" + Send-DiscordWin -Content "[2D Replay](<$replayUrl>)" + Send-DiscordWin -Content "More match details [DTCH_STATS]()" + + Write-Output "Sent win report for match $winId." + Start-Sleep -Seconds 2 # Small delay between messages + + } # End foreach winId + + # --- Clear New Match Lists in Data File --- + Write-Output "Clearing new win/loss lists in player matches data file..." + $updatedPlayerMatchesData = $playerMatchesData # Start with the loaded data + $winListCleared = $false + $lossListCleared = $false + + # Iterate through the array to find and modify the special entries + for ($i = 0; $i -lt $updatedPlayerMatchesData.Count; $i++) { + $item = $updatedPlayerMatchesData[$i] + if ($item -is [PSCustomObject]) { + if ($item.PSObject.Properties.Name -eq 'new_win_matches') { + $item.new_win_matches = @() # Clear the list + $winListCleared = $true + } + if ($item.PSObject.Properties.Name -eq 'new_loss_matches') { + $item.new_loss_matches = @() # Clear the list + $lossListCleared = $true } - } - + # Stop if both found and cleared + if ($winListCleared -and $lossListCleared) { break } } - write-output "New win matches:" - $new_win_matches - if ($new_win_matches.count -le 10) { - $content_winstats = '```' + ($win_stats | Format-Table | out-string) + '```' - send-discord -content $content_winstats - - if ($victims.count -ge 1) { - send-discord -content ":skull::skull: Helaas hebben we deze keer ook team killers :skull::skull: " - $content_victims = '```' + ($victims | Format-Table | out-string) + '```' - send-discord -content $content_victims - } - - send-discord -content "[2D replay](<$2D_replay_url>)" - send-discord -content "More match details [DTCH_STATS]()" - } - else { - write-output "Something went wrong (more then 10 matches to report)" - } - $legenda = ' -``` -dmg_h = Schade aangericht aan echte spelers -dmg = Totale schade (aan zowel echte spelers als AI) -k_h = Aantal echte spelers die je hebt geelimineerd -K_a = Totale aantal eliminaties (inclusief AI) -t_serv = Overleefde tijd (in minuten) -k_t = Team eliminaties -``` - ' - #send-discord -content $legenda - -} - -foreach ($item in $player_matches) { - if ($item.PSObject.Properties.Name -contains "new_win_matches") { - $item.new_win_matches = $null - $item.new_loss_matches = $null + if ($winListCleared -or $lossListCleared) { + try { + $updatedPlayerMatchesData | ConvertTo-Json -Depth 100 | Out-File -FilePath $playerMatchesJsonPath -Encoding UTF8 -ErrorAction Stop + Write-Output "Successfully cleared new match lists in '$playerMatchesJsonPath'." + } catch { + Write-Error "Failed to save player matches data after clearing lists. '$playerMatchesJsonPath'. Error: $($_.Exception.Message)" + } + } else { + Write-Warning "Could not find 'new_win_matches' or 'new_loss_matches' entries to clear in '$playerMatchesJsonPath'." } -} -# Convert back to JSON (optional) -$newJson = $player_matches | ConvertTo-Json -Depth 100 - -# Display the updated JSON -$newJson | out-file "$scriptroot/../data/player_matches.json" - -remove-lock -Stop-Transcript \ No newline at end of file +} # End Main Try Block +finally { + # --- Cleanup --- + Write-Output "Script finished at $(Get-Date)." + Remove-Lock # Ensure lock is always removed + if ($transcriptPath -and (Get-Transcript | Select-Object -ExpandProperty Path) -eq $transcriptPath) { + Stop-Transcript + } +} \ No newline at end of file diff --git a/discord/report_to_discord.ps1 b/discord/report_to_discord.ps1 index c72c8eb..8cdc0b6 100644 --- a/discord/report_to_discord.ps1 +++ b/discord/report_to_discord.ps1 @@ -1,60 +1,139 @@ -Start-Transcript -Path '/var/log/report_new_matches.log' -Append +# --- Script Setup --- +# Note: Transcript path seems incorrect, should likely be specific to this script. +# Consider changing to '/var/log/dtch/report_to_discord.log' or similar. +Start-Transcript -Path '/var/log/dtch/report_to_discord.log' -Append +Write-Output "Starting report_to_discord script at $(Get-Date)" +Write-Output "Running from: $(Get-Location)" -if ($PSScriptRoot.length -eq 0) { - $scriptroot = Get-Location +# Determine script root directory reliably +if ($PSScriptRoot) { + $scriptRoot = $PSScriptRoot +} else { + $scriptRoot = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition + Write-Warning "PSScriptRoot not defined, using calculated path: $scriptRoot" } -else { - $scriptroot = $PSScriptRoot -} -. $scriptroot\..\includes\ps1\lockfile.ps1 -new-lock -by "report_to_discord" -function IsValidEntry($entry) { - return ($entry.KD_H -ne 'NaN' -and $entry.KD_ALL -ne 'NaN') -and - ($entry.KD_H -ne 'Infinity' -and $entry.KD_ALL -ne 'Infinity') -} -$fileContent = Get-Content -Path "$scriptroot/../discord/config.php" -Raw +Write-Output "Script root identified as: $scriptRoot" -# Use regex to match the apiKey value -if ($fileContent -match "\`$webhookurl\s*=\s*\'([^\']+)\'") { - $webhookurl = $matches[1] -} -else { - Write-Output "API Key not found" -} +# Define paths using Join-Path +$includesPath = Join-Path -Path $scriptRoot -ChildPath "..\includes\ps1" +$discordConfigPath = Join-Path -Path $scriptRoot -ChildPath "config.php" # Config is in the same dir +$dataPath = Join-Path -Path $scriptRoot -ChildPath "..\data" +$lastStatsJsonPath = Join-Path -Path $dataPath -ChildPath "player_last_stats.json" -$stats = get-content "$scriptroot/../data/player_last_stats.json" | convertfrom-json +# --- Locking --- +$lockFilePath = Join-Path -Path $includesPath -ChildPath "lockfile.ps1" +if (-not (Test-Path -Path $lockFilePath -PathType Leaf)) { + Write-Error "Lockfile script not found at '$lockFilePath'. Cannot proceed." + Stop-Transcript + exit 1 +} +. $lockFilePath +New-Lock -by "report_to_discord" -ErrorAction Stop # Stop if locking fails +# --- Main Logic in Try/Finally for Lock Removal --- +try { + # --- Helper Function --- + # Checks if K/D values are valid numbers (not NaN or Infinity) + function Test-IsValidKdEntry { + param($entry) + + # Check if properties exist and are numeric before comparison + $kdHValid = $entry.PSObject.Properties.Name -contains 'KD_H' -and $entry.KD_H -is [double] -and -not [double]::IsNaN($entry.KD_H) -and -not [double]::IsInfinity($entry.KD_H) + $kdAllValid = $entry.PSObject.Properties.Name -contains 'KD_ALL' -and $entry.KD_ALL -is [double] -and -not [double]::IsNaN($entry.KD_ALL) -and -not [double]::IsInfinity($entry.KD_ALL) + + return $kdHValid -and $kdAllValid + } -$filteredData = @{ - all = $stats.all | Where-Object { IsValidEntry $_ } -} + # --- Configuration Loading --- + $webhookUrl = $null + if (Test-Path -Path $discordConfigPath -PathType Leaf) { + try { + $fileContent = Get-Content -Path $discordConfigPath -Raw -ErrorAction Stop + # Corrected regex for webhookurl + if ($fileContent -match '^\s*\$webhookurl\s*=\s*''([^'']+)''') { + $webhookUrl = $matches[1] + Write-Output "Discord webhook URL loaded successfully." + } else { + Write-Warning "Webhook URL pattern not found in '$discordConfigPath'." + } + } catch { + Write-Warning "Failed to read '$discordConfigPath'. Error: $($_.Exception.Message)" + } + } else { + Write-Warning "Discord config file not found at '$discordConfigPath'." + } -$most_kills = @{ - 'name' = (($filteredData.all | Sort-Object kills -Descending)[0].playername) - 'stat' = (($filteredData.all | Sort-Object kills -Descending)[0].kills) -} -$most_deaths = @{ - 'name' = (($filteredData.all | Sort-Object deaths -Descending)[0].playername) - 'stat' = (($filteredData.all | Sort-Object deaths -Descending)[0].deaths) -} -$most_humankills = @{ - 'name' = (($filteredData.all | Sort-Object humankills -Descending)[0].playername) - 'stat' = (($filteredData.all | Sort-Object humankills -Descending)[0].humankills) -} -$most_KD_H = @{ - 'name' = (($filteredData.all | Sort-Object KD_H -Descending)[0].playername) - 'stat' = (($filteredData.all | Sort-Object KD_H -Descending)[0].KD_H) -} -$most_KD_ALL = @{ - 'name' = (($filteredData.all | Sort-Object KD_ALL -Descending)[0].playername) - 'stat' = (($filteredData.all | Sort-Object KD_ALL -Descending)[0].KD_ALL) -} -$most_matches = @{ - 'name' = (($filteredData.all | Sort-Object matches -Descending)[0].playername) - 'stat' = (($filteredData.all | Sort-Object matches -Descending)[0].matches) -} + if (-not $webhookUrl) { + Write-Error "Discord webhook URL could not be loaded. Cannot send report." + throw "Missing Webhook URL" # Throw to trigger finally block + } -$content = " + # --- Load Stats Data --- + $statsData = $null + if (Test-Path -Path $lastStatsJsonPath -PathType Leaf) { + try { + $statsData = Get-Content -Path $lastStatsJsonPath | ConvertFrom-Json -ErrorAction Stop + if ($null -eq $statsData) { + Write-Error "Failed to parse stats data from '$lastStatsJsonPath'. Cannot generate report." + throw "Invalid Stats Data" + } + Write-Output "Successfully loaded player stats data." + } catch { + Write-Error "Error reading '$lastStatsJsonPath': $($_.Exception.Message). Cannot generate report." + throw $_ + } + } else { + Write-Error "Player stats file not found at '$lastStatsJsonPath'. Cannot generate report." + throw "Missing Stats File" + } + + # --- Process Stats --- + Write-Output "Processing stats..." + # Safely access the 'all' category and filter valid entries + $filteredAllStats = @() + if ($statsData.PSObject.Properties.Name -contains 'all' -and $statsData.all -is [array]) { + $filteredAllStats = $statsData.all | Where-Object { Test-IsValidKdEntry $_ } + Write-Output "Found $($filteredAllStats.Count) valid entries in 'all' category." + } else { + Write-Warning "Stats data does not contain a valid 'all' array. Report might be incomplete." + } + + # --- Find Top Players (Handle Empty/Null Cases) --- + # Helper to safely get top player stat + function Get-TopStat { + param( + [array]$Data, + [string]$Property, + [string]$DefaultName = "N/A", + [string]$DefaultStat = "N/A" + ) + $sorted = $Data | Sort-Object $Property -Descending -ErrorAction SilentlyContinue + if ($sorted -and $sorted.Count -gt 0) { + # Ensure the property exists on the top object before accessing + $topEntry = $sorted[0] + $playerName = if ($topEntry.PSObject.Properties.Name -contains 'playername') { $topEntry.playername } else { $DefaultName } + $statValue = if ($topEntry.PSObject.Properties.Name -contains $Property) { $topEntry.$Property } else { $DefaultStat } + # Format numeric stats if needed (e.g., K/D) + if ($Property -like "KD*" -and $statValue -is [double]) { $statValue = "{0:N2}" -f $statValue } + + return @{ 'name' = $playerName; 'stat' = $statValue } + } else { + return @{ 'name' = $DefaultName; 'stat' = $DefaultStat } + } + } + + $mostKills = Get-TopStat -Data $filteredAllStats -Property 'kills' + $mostDeaths = Get-TopStat -Data $filteredAllStats -Property 'deaths' + $mostHumanKills = Get-TopStat -Data $filteredAllStats -Property 'humankills' + $mostKdH = Get-TopStat -Data $filteredAllStats -Property 'KD_H' + $mostKdAll = Get-TopStat -Data $filteredAllStats -Property 'KD_ALL' + $mostMatches = Get-TopStat -Data $filteredAllStats -Property 'matches' + + Write-Output "Determined top players for report." + + # --- Format Discord Message --- + # Using PowerShell multi-line string with variable substitution + $reportContent = @" :rocket: Het maandelijks raportje :rocket: Hey toppers! @@ -62,22 +141,22 @@ Hey toppers! Laten we eens duiken in de cijfers van onze supergamers van de afgelopen maand: :dart: Meeste Kills: -Hats off voor **$($most_kills['name'])**! Met **$($most_kills['stat'])** kills is hij/zij onze scherpschutter van de maand! +Hats off voor **$($mostKills.name)**! Met **$($mostKills.stat)** kills is hij/zij onze scherpschutter van de maand! :skull_crossbones: Meeste Deaths: -Oei, oei, oei... **$($most_deaths['name'])** is helaas het vaakst naar het hiernamaals gestuurd met **$($most_deaths['stat'])** deaths. Kop op, volgende keer beter! +Oei, oei, oei... **$($mostDeaths.name)** is helaas het vaakst naar het hiernamaals gestuurd met **$($mostDeaths.stat)** deaths. Kop op, volgende keer beter! :robot: Meeste Humankills: -Watch out! We hebben een Terminator onder ons. Hoedje af voor **$($most_humankills['name'])** met **$($most_humankills['stat'])** humankills! +Watch out! We hebben een Terminator onder ons. Hoedje af voor **$($mostHumanKills.name)** met **$($mostHumanKills.stat)** humankills! :bar_chart: Beste KD Ratio (Alle vijanden): -De onevenaarbare **$($most_KD_ALL['name'])** heeft een KD van **$($most_KD_ALL['stat'])** ! Niet slecht, toch? 😉 +De onevenaarbare **$($mostKdAll.name)** heeft een KD van **$($mostKdAll.stat)** ! Niet slecht, toch? 😉 :adult: Beste KD Ratio (Alleen menselijke spelers): -Opgelet, gamers! **$($most_KD_H['name'])** heeft een KD van **$($most_KD_H['stat'])** tegen andere spelers! Wie daagt hem/haar uit? +Opgelet, gamers! **$($mostKdH.name)** heeft een KD van **$($mostKdH.stat)** tegen andere spelers! Wie daagt hem/haar uit? :video_game: Meeste Matches: -Onze meest toegewijde gamer, **$($most_matches['name'])**, heeft maar liefst **$($most_matches['stat'])** matches gespeeld. Ga zo door! +Onze meest toegewijde gamer, **$($mostMatches.name)**, heeft maar liefst **$($mostMatches.stat)** matches gespeeld. Ga zo door! Da's het voor nu, gamers! Blijf schieten, blijf lachen en tot het volgende rapportje! @@ -85,18 +164,33 @@ High fives en knuffels (virtueel, natuurlijk), Het Gaming Team Meer stats zijn hier te vinden : https://lanta.eu/DTCH -" +"@ -$content + Write-Output "Formatted Discord message content." + # Write-Verbose $reportContent # Uncomment to see the full message in verbose logs + # --- Send to Discord --- + $payload = @{ + content = $reportContent + # username = "Stats Bot" # Optional: Override bot name + # avatar_url = "" # Optional: Override bot avatar + } | ConvertTo-Json -Depth 3 # Depth 3 should be sufficient + Write-Output "Sending report to Discord webhook..." + try { + Invoke-RestMethod -Uri $webhookUrl -Method Post -Body $payload -ContentType 'application/json' -ErrorAction Stop + Write-Output "Report successfully sent to Discord." + } catch { + $errorMessage = $_.Exception.Message + Write-Error "Failed to send report to Discord. Error: $errorMessage" + # Consider logging the failed payload or response details if available + # Write-Warning "Payload: $payload" + } -$payload = [PSCustomObject]@{ - - content = $content - -} - -Invoke-RestMethod -Uri $webhookurl -Method Post -Body ($payload | ConvertTo-Json) -ContentType 'Application/Json' -remove-lock -Stop-Transcript \ No newline at end of file +} # End Main Try Block +finally { + # --- Cleanup --- + Write-Output "Script finished at $(Get-Date)." + Remove-Lock # Ensure lock is always removed + Stop-Transcript +} \ No newline at end of file diff --git a/discord/teammakerv2.py b/discord/teammakerv2.py index ff7858e..9cd148e 100644 --- a/discord/teammakerv2.py +++ b/discord/teammakerv2.py @@ -1,255 +1,567 @@ -import json +# -*- coding: utf-8 -*- +""" +Discord bot for team creation, stats reporting, and event logging. +""" + +import json import os import discord import random import asyncio import re +import logging from discord.ext import commands +# --- Configuration --- -def get_token(): - with open("config.php", "r") as file: - content = file.read() - match = re.search(r"bottoken\s*=\s*'(.+?)'", content) - if match: - return match.group(1) - return None +# TODO: Move hardcoded channel names to a config file or environment variables +TEAMIFY_VOICE_CHANNEL = "teamify" +TEAMIFY_TEXT_CHANNEL = "teamify" +TEMP_CATEGORY_NAME = "Temporary Teams" +LOGGING_CHANNEL = "logging" +WELCOME_CHANNEL = "raadhuisplein" +GOD_CHANNEL = "GOD_CHANNEL" # Channel to ignore for voice logging -token = get_token() +# TODO: Move stats file path to config +STATS_FILE_PATH = os.path.join("..", "data", "player_last_stats.json") +CONFIG_PHP_PATH = "config.php" # Path relative to this script's location + +# --- Logging Setup --- +# Basic logging to console +logging.basicConfig(level=logging.INFO, format='%(asctime)s:%(levelname)s:%(name)s: %(message)s') +logger = logging.getLogger('discord_bot') + +# --- Helper Functions --- + +def get_token_from_php(config_path): + """ + Reads the bot token from a PHP config file. + + WARNING: This method of storing secrets is insecure. + Consider using environment variables or a dedicated secrets management solution. + """ + try: + with open(config_path, "r", encoding="utf-8") as file: + content = file.read() + # Regex to find $bottoken = 'TOKEN_VALUE'; + match = re.search(r"\$bottoken\s*=\s*'(.+?)'", content) + if match: + logger.info("Successfully extracted bot token from %s", config_path) + return match.group(1) + else: + logger.error("Bot token pattern not found in %s", config_path) + return None + except FileNotFoundError: + logger.error("Config file not found at %s", config_path) + return None + except Exception as e: + logger.exception("Error reading or parsing config file %s: %s", config_path, e) + return None + +async def cleanup_empty_channels(guild, temp_channels, ctx_channel): + """ + Periodically checks and removes empty temporary voice channels. + """ + while temp_channels: + await asyncio.sleep(60) # Check every 60 seconds + for channel in temp_channels[:]: # Iterate over a copy + try: + # Refresh channel state + current_channel = guild.get_channel(channel.id) + if current_channel and isinstance(current_channel, discord.VoiceChannel): + if len(current_channel.members) == 0: + logger.info("Deleting empty temporary channel: %s", current_channel.name) + await current_channel.delete(reason="Channel empty") + temp_channels.remove(channel) + # Optionally notify in the original context channel + # await ctx_channel.send(f"Kanaal {channel.name} is opgeruimd omdat het leeg was!") + elif current_channel is None: + # Channel might have been deleted manually + logger.warning("Temporary channel %s (ID: %d) not found, removing from tracking list.", channel.name, channel.id) + temp_channels.remove(channel) + + except discord.Forbidden: + logger.error("Missing permissions to delete channel %s", channel.name) + # Remove from list to avoid repeated attempts if permissions are missing + temp_channels.remove(channel) + except discord.NotFound: + logger.warning("Attempted to delete channel %s but it was already gone.", channel.name) + if channel in temp_channels: + temp_channels.remove(channel) + except Exception as e: + logger.exception("Error during temporary channel cleanup for %s: %s", channel.name, e) + # Remove from list to avoid potential infinite loops on persistent errors + if channel in temp_channels: + temp_channels.remove(channel) + +# --- Bot Setup --- + +token = get_token_from_php(CONFIG_PHP_PATH) if not token: - raise ValueError("Bot token niet gevonden in config.php") + logger.critical("Bot token not found or could not be read. Exiting.") + exit() # Exit if token is essential -# Intents instellen +# Define Intents intents = discord.Intents.default() intents.voice_states = True intents.guilds = True intents.messages = True intents.message_content = True -intents.presences = True # Nodig als de bot presences moet zien -intents.members = True # Nodig om leden in een voice channel te zien +intents.presences = True # Required if the bot needs to see presences +intents.members = True # Required to see members in voice channels and for join/remove events bot = commands.Bot(command_prefix="!", intents=intents) +# --- Events --- + @bot.event async def on_ready(): - print(f'Bot is ingelogd als {bot.user}') + """Called when the bot is ready and connected.""" + logger.info('Bot is ingelogd als %s (ID: %s)', bot.user, bot.user.id) + print(f'Bot is ingelogd als {bot.user}') # Keep console output for convenience + +@bot.event +async def on_command_error(ctx, error): + """Global command error handler.""" + if isinstance(error, commands.CommandNotFound): + # Optionally ignore 'Command not found' errors or send a message + # await ctx.send("Onbekend commando.") + logger.warning("Command not found: %s", ctx.message.content) + elif isinstance(error, commands.MissingRequiredArgument): + await ctx.send(f"Je mist een argument: {error.param.name}") + elif isinstance(error, commands.BadArgument): + await ctx.send("Ongeldig argument opgegeven.") + elif isinstance(error, commands.CheckFailure): + await ctx.send("Je hebt geen permissies voor dit commando of kanaal.") + else: + # Log other errors + logger.exception("Unhandled command error in '%s': %s", ctx.command, error) + try: + # Attempt to notify user about unexpected error + await ctx.send("Er is een onverwachte fout opgetreden.") + except discord.HTTPException: + pass # Ignore if we can't send the error message + +@bot.event +async def on_voice_state_update(member, before, after): + """Logs user movements between voice channels.""" + # Ignore bots + if member.bot: + return + + logging_channel = discord.utils.get(member.guild.text_channels, name=LOGGING_CHANNEL) + if not logging_channel: + logger.warning("Logging channel '%s' not found.", LOGGING_CHANNEL) + return + + # Ignore movements involving the GOD_CHANNEL + if (before.channel and before.channel.name == GOD_CHANNEL) or \ + (after.channel and after.channel.name == GOD_CHANNEL): + return + + try: + if before.channel is None and after.channel is not None: + # Member joins a voice channel + logger.info("%s joined voice channel: %s", member.display_name, after.channel.name) + await logging_channel.send(f"🔊 {member.mention} is gejoined in voice kanaal: **{after.channel.name}**") + elif before.channel is not None and after.channel is None: + # Member leaves a voice channel + logger.info("%s left voice channel: %s", member.display_name, before.channel.name) + await logging_channel.send(f"🔇 {member.mention} heeft het voice kanaal **{before.channel.name}** verlaten.") + elif before.channel != after.channel: + # Member switches voice channels + logger.info("%s switched from %s to %s", member.display_name, before.channel.name, after.channel.name) + await logging_channel.send(f"🔄 {member.mention} is van **{before.channel.name}** naar **{after.channel.name}** gegaan.") + except discord.Forbidden: + logger.error("Missing permissions to send message in logging channel '%s'", LOGGING_CHANNEL) + except Exception as e: + logger.exception("Error during on_voice_state_update: %s", e) + +@bot.event +async def on_member_join(member): + """Welcomes new members.""" + if member.bot: return # Ignore bots + + welcome_channel = discord.utils.get(member.guild.text_channels, name=WELCOME_CHANNEL) + if welcome_channel: + logger.info("Member joined: %s", member.display_name) + try: + await welcome_channel.send(f"🎉 Welkom {member.mention} op de server! We hopen dat je een leuke tijd hebt!") + except discord.Forbidden: + logger.error("Missing permissions to send message in welcome channel '%s'", WELCOME_CHANNEL) + except Exception as e: + logger.exception("Error during on_member_join: %s", e) + else: + logger.warning("Welcome channel '%s' not found.", WELCOME_CHANNEL) + +@bot.event +async def on_member_remove(member): + """Logs when members leave.""" + if member.bot: return # Ignore bots + + goodbye_channel = discord.utils.get(member.guild.text_channels, name=WELCOME_CHANNEL) # Using same channel as welcome + if goodbye_channel: + logger.info("Member left: %s", member.display_name) + try: + await goodbye_channel.send(f"😢 {member.display_name} heeft de server verlaten. We zullen je missen!") # Use display_name as mention won't work + except discord.Forbidden: + logger.error("Missing permissions to send message in goodbye channel '%s'", WELCOME_CHANNEL) + except Exception as e: + logger.exception("Error during on_member_remove: %s", e) + else: + logger.warning("Goodbye channel '%s' not found.", WELCOME_CHANNEL) + + +# --- Commands --- @bot.command() async def test(ctx): + """A simple test command.""" await ctx.send("Test geslaagd!") @bot.command() async def teamify(ctx, *args): - for arg in args: - if arg.lower() == "help": - help_message = ( - "**Gebruik van !teamify:**\n" - "`!teamify` - Verdeel spelers in teams van max. 4 personen.\n" - "`!teamify ` - Verdeel spelers in een opgegeven aantal teams.\n" - "`!teamify move` - Verdeel spelers en verplaats ze naar tijdelijke voice-kanalen.\n" - "`!teamify move` - Verdeel spelers automatisch en verplaats ze naar tijdelijke voice-kanalen." - ) - await ctx.send(help_message) - return + """ + Splits members in the 'teamify' voice channel into random teams. - # Beperk het commando tot alleen het kanaal "teamify" - if ctx.channel.name != "teamify": - await ctx.send("Dit commando kan alleen worden gebruikt in het #teamify kanaal.") - return - - guild = ctx.guild - voice_channel = discord.utils.get(guild.voice_channels, name="teamify") - - if not voice_channel or len(voice_channel.members) == 0: - await ctx.send("Er zijn geen mensen in het kanaal 'teamify' om teams van te maken!") - return - - members = voice_channel.members - random.shuffle(members) - - # Standaardwaarden - num_teams = 0 - move_players = False - - # Verwerk argumenten - for arg in args: - if arg.isdigit(): # Als het een getal is, gebruik het als het aantal teams - num_teams = int(arg) - elif arg.lower() == "move": # Als 'true' is opgegeven, verplaats spelers - move_players = True - - # Bepaal het aantal teams als niet opgegeven - if num_teams <= 0: - num_teams = len(members) // 4 if len(members) >= 4 else 1 - - num_teams = min(num_teams, len(members)) - teams = [[] for _ in range(num_teams)] - for i, member in enumerate(members): - teams[i % num_teams].append(member) - - # Zoek het tekstkanaal "teamify" - text_channel = discord.utils.get(guild.text_channels, name="teamify") - if not text_channel: - await ctx.send("Het kanaal 'teamify' bestaat niet!") - return - - message = f"Willekeurige teams uit {voice_channel.name}:\n\n" - category = discord.utils.get(guild.categories, name="Temporary Teams") - if not category: - category = await guild.create_category("Temporary Teams") - - temp_channels = [] - - for i, team in enumerate(teams, start=1): - team_names = ', '.join([member.mention for member in team]) - message += f"**Team {i}:** {team_names}\n" - - if move_players: - temp_channel = await guild.create_voice_channel(f"Squad {i}", category=category) - temp_channels.append(temp_channel) - for member in team: - await member.move_to(temp_channel) - - await text_channel.send(message) - await ctx.send("Teams zijn gepost in #teamify!" + (" Spelers zijn verplaatst naar tijdelijke kanalen." if move_players else "")) - - # Controleer continu of de kanalen leeg zijn en verwijder ze - if move_players: - while temp_channels: - await asyncio.sleep(60) # Controleer elke minuut - for channel in temp_channels[:]: - if len(channel.members) == 0: - if channel in guild.voice_channels: # Controleer of het kanaal nog bestaat - await channel.delete() - temp_channels.remove(channel) - await ctx.send(f"Kanaal {channel.name} is opgeruimd omdat het leeg was!") - -@bot.command() -async def whoisbest(ctx, category="Casual", matchesback=18): - - - if category.lower() == "help": + Usage: + !teamify - Auto-split into teams of 4. + !teamify - Split into a specific number of teams. + !teamify move - Auto-split and move to temporary channels. + !teamify move - Split into specific teams and move. + !teamify help - Show help message. + """ + # Handle help argument first + if "help" in [arg.lower() for arg in args]: help_message = ( - "**Gebruik van het commando `whoisbest`:**\n" - "`!whoisbest [category] [matchesback]`\n\n" - "**Parameters:**\n" - "`category` - De categorie van de stats, bijv. 'Casual' of 'Ranked'. Niet hoofdlettergevoelig.\n" - "`matchesback` - Het minimum aantal matches dat een speler gespeeld moet hebben om mee te tellen (standaard 18).\n\n" - "**Voorbeeld:**\n" - "`!whoisbest Casual 18`\n" - "Laat de top 3 spelers zien op basis van winratio en gemiddelde damage in de Casual categorie met minimaal 18 matches.\n\n" - "Typ `!whoisbest help` om deze uitleg opnieuw te zien." + "**Gebruik van !teamify:**\n" + f"`!teamify` - Verdeel spelers in `{TEAMIFY_VOICE_CHANNEL}` in teams van max. 4.\n" + "`!teamify ` - Verdeel spelers in een opgegeven aantal teams.\n" + "`!teamify move` - Verdeel spelers automatisch en verplaats ze naar tijdelijke kanalen.\n" + "`!teamify move` - Verdeel spelers en verplaats ze.\n" + "`!teamify help` - Toon dit bericht." ) await ctx.send(help_message) return + # Check if command is used in the correct text channel + if ctx.channel.name != TEAMIFY_TEXT_CHANNEL: + await ctx.send(f"Dit commando kan alleen worden gebruikt in het #{TEAMIFY_TEXT_CHANNEL} kanaal.") + return - # Bestandspad - file_path = os.path.join("..", "data", "player_last_stats.json") - + guild = ctx.guild + source_voice_channel = discord.utils.get(guild.voice_channels, name=TEAMIFY_VOICE_CHANNEL) + + if not source_voice_channel: + await ctx.send(f"Voice kanaal '{TEAMIFY_VOICE_CHANNEL}' niet gevonden!") + return + + if not source_voice_channel.members: + await ctx.send(f"Er zijn geen mensen in het kanaal '{TEAMIFY_VOICE_CHANNEL}' om teams van te maken!") + return + + members = list(source_voice_channel.members) # Get a mutable list + random.shuffle(members) + member_count = len(members) + + # Parse arguments + num_teams_arg = 0 + move_players = False + for arg in args: + if arg.isdigit(): + num_teams_arg = int(arg) + elif arg.lower() == "move": + move_players = True + + # Determine number of teams + if num_teams_arg <= 0: + # Auto-calculate based on team size 4, minimum 1 team + num_teams = (member_count + 3) // 4 if member_count > 0 else 1 + else: + num_teams = num_teams_arg + + # Ensure num_teams is valid + num_teams = max(1, min(num_teams, member_count)) # At least 1 team, max members count + + # Create teams + teams = [[] for _ in range(num_teams)] + for i, member in enumerate(members): + teams[i % num_teams].append(member) + + # Find the output text channel + output_text_channel = discord.utils.get(guild.text_channels, name=TEAMIFY_TEXT_CHANNEL) + if not output_text_channel: + # Fallback to context channel if specific channel not found + logger.warning("Output text channel '%s' not found, using context channel.", TEAMIFY_TEXT_CHANNEL) + output_text_channel = ctx.channel + # await ctx.send(f"Tekst kanaal '{TEAMIFY_TEXT_CHANNEL}' bestaat niet! Resultaten worden hier gepost.") + + # Prepare message and temporary channels if moving + message = f"Willekeurige teams uit {source_voice_channel.mention}:\n\n" + temp_channels = [] + category = None + + if move_players: + try: + category = discord.utils.get(guild.categories, name=TEMP_CATEGORY_NAME) + if not category: + logger.info("Creating category '%s'", TEMP_CATEGORY_NAME) + category = await guild.create_category(TEMP_CATEGORY_NAME, reason="Teamify temporary channels") + except discord.Forbidden: + await ctx.send(f"Ik heb geen permissies om de categorie '{TEMP_CATEGORY_NAME}' te maken.") + move_players = False # Disable moving if category creation fails + logger.error("Missing permissions to create category '%s'", TEMP_CATEGORY_NAME) + except Exception as e: + await ctx.send("Fout bij het maken/vinden van de categorie.") + move_players = False + logger.exception("Error finding/creating category '%s': %s", TEMP_CATEGORY_NAME, e) + + # Build message and move players + move_tasks = [] + for i, team in enumerate(teams, start=1): + team_names = ', '.join([member.mention for member in team]) + message += f"**Team {i}:** {team_names}\n" + + if move_players and category: + try: + channel_name = f"Squad {i}" + logger.info("Creating temporary voice channel: %s", channel_name) + temp_channel = await guild.create_voice_channel(channel_name, category=category, reason="Teamify command") + temp_channels.append(temp_channel) + for member in team: + # Add move operation to a list to run concurrently + move_tasks.append(member.move_to(temp_channel)) + except discord.Forbidden: + await ctx.send(f"Ik heb geen permissies om voice kanaal 'Squad {i}' te maken of leden te verplaatsen.") + logger.error("Missing permissions to create/move for Squad %d", i) + # Optionally stop creating more channels if one fails + except Exception as e: + await ctx.send(f"Fout bij het maken van kanaal 'Squad {i}'.") + logger.exception("Error creating channel 'Squad %d': %s", i, e) + + # Send the team message try: - # JSON-bestand lezen - with open(file_path, "r", encoding="utf-8") as file: + await output_text_channel.send(message) + logger.info("Posted team message to #%s", output_text_channel.name) + except discord.Forbidden: + await ctx.send(f"Ik heb geen permissies om berichten te sturen in #{output_text_channel.name}.") + logger.error("Missing permissions to send message in #%s", output_text_channel.name) + except Exception as e: + await ctx.send("Fout bij het versturen van het team bericht.") + logger.exception("Error sending team message: %s", e) + + # Execute moves concurrently + if move_tasks: + logger.info("Moving %d members to temporary channels...", len(move_tasks)) + results = await asyncio.gather(*move_tasks, return_exceptions=True) + moved_count = 0 + for i, result in enumerate(results): + member_to_move = move_tasks[i].__self__ # Get the member object from the coroutine + if isinstance(result, Exception): + logger.error("Failed to move member %s: %s", member_to_move.display_name, result) + try: + # Try to notify in context channel about failed move + await ctx.send(f"Kon {member_to_move.mention} niet verplaatsen: {result}") + except discord.HTTPException: pass # Ignore if sending fails + else: + moved_count += 1 + logger.info("Finished moving members. Successful moves: %d", moved_count) + await ctx.send(f"Teams zijn gepost in #{output_text_channel.name}! {moved_count} speler(s) verplaatst.") + elif move_players: # If move was intended but failed (e.g., category issue) + await ctx.send(f"Teams zijn gepost in #{output_text_channel.name}. Kon spelers niet verplaatsen.") + else: + await ctx.send(f"Teams zijn gepost in #{output_text_channel.name}.") + + + # Start background task for cleaning up empty channels if channels were created + if temp_channels: + logger.info("Starting background task to clean up %d temporary channels.", len(temp_channels)) + bot.loop.create_task(cleanup_empty_channels(guild, temp_channels, ctx.channel)) + + +@bot.command() +async def whoisbest(ctx, category="Casual", matchesback: int = 18): + """ + Shows top 3 players by winrate and AHD for a given category. + + Usage: !whoisbest [category] [min_matches] + Example: !whoisbest Ranked 10 + """ + if category.lower() == "help": + help_message = ( + "**Gebruik van `!whoisbest`:**\n" + "`!whoisbest [category] [min_matches]`\n\n" + "**Parameters:**\n" + "`category` - Stats categorie (bijv. 'Casual', 'Ranked', 'Intense'). Niet hoofdlettergevoelig. Standaard: 'Casual'.\n" + "`min_matches` - Minimum aantal matches gespeeld. Standaard: 18.\n\n" + "**Voorbeeld:** `!whoisbest Ranked 10`\n" + "Toont top 3 Win% en AHD voor Ranked met minimaal 10 matches.\n" + ) + await ctx.send(help_message) + return + + logger.info("Whoisbest command invoked: category=%s, matchesback=%d", category, matchesback) + + try: + # Read JSON file + if not os.path.exists(STATS_FILE_PATH): + await ctx.send(f"Statistieken bestand niet gevonden ({STATS_FILE_PATH}).") + logger.error("Stats file not found: %s", STATS_FILE_PATH) + return + + with open(STATS_FILE_PATH, "r", encoding="utf-8") as file: data = json.load(file) - # Mapping maken (lowercase -> originele categorie) - category_mapping = {cat.lower(): cat for cat in data.keys()} + # Create case-insensitive mapping for categories + category_mapping = {cat.lower(): cat for cat in data.keys() if isinstance(data[cat], list)} # Only map list categories - # Zet de opgegeven category om naar lowercase lower_category = category.lower() if lower_category not in category_mapping: - available_categories = ', '.join(data.keys()) - await ctx.send(f"Ongeldige categorie '{category}'! Beschikbare categorieën: {available_categories}") + available_categories = ', '.join(category_mapping.values()) # Show original names + await ctx.send(f"Ongeldige categorie '{category}'. Beschikbaar: {available_categories}") + logger.warning("Invalid category requested: %s", category) return - - # Gebruik de juiste (originele) categorie naam uit de mapping + actual_category = category_mapping[lower_category] + logger.info("Processing category: %s", actual_category) - players = [player for player in data.get(actual_category, []) if player.get("matches", 0) >= matchesback] + # Filter players based on minimum matches + players_in_category = data.get(actual_category, []) + if not isinstance(players_in_category, list): + await ctx.send(f"Data voor categorie '{actual_category}' is niet in het verwachte formaat.") + logger.error("Invalid data format for category '%s'", actual_category) + return - if not players: - await ctx.send(f"Geen spelersstatistieken gevonden voor '{actual_category}' met minimaal {matchesback} gespeelde matches!") + filtered_players = [ + player for player in players_in_category + if isinstance(player, dict) and player.get("matches", 0) >= matchesback + ] + logger.info("Found %d players meeting criteria (>=%d matches) in %s", len(filtered_players), matchesback, actual_category) + + if not filtered_players: + await ctx.send(f"Geen spelers gevonden voor '{actual_category}' met ≥ {matchesback} matches.") return - # Sorteer spelers op winratio (aflopend) - top_winratio = sorted(players, key=lambda x: x.get("winratio", 0), reverse=True)[:3] + # Safely sort players, handling potential missing keys or non-numeric data + def safe_get_float(player_dict, key, default=0.0): + val = player_dict.get(key) + if isinstance(val, (int, float)): + return float(val) + # Try converting comma decimal separator if needed + if isinstance(val, str): + try: return float(val.replace(',', '.')) + except ValueError: return default + return default - # Sorteer spelers op gemiddelde damage (aflopend) - top_ahd = sorted(players, key=lambda x: x.get("ahd", 0), reverse=True)[:3] + top_winratio = sorted(filtered_players, key=lambda x: safe_get_float(x, "winratio"), reverse=True)[:3] + top_ahd = sorted(filtered_players, key=lambda x: safe_get_float(x, "ahd"), reverse=True)[:3] - # Bouw het bericht op - message = f"**\U0001F3C6 Top 3 Winratio ({actual_category})**\n" - for i, player in enumerate(top_winratio, start=1): - message += f"{i}. **{player['playername']}** - {player['winratio']:.2f}%\n" + # Build the response message + response_message = f"**📊 Top Stats voor '{actual_category}' (min {matchesback} matches)**\n\n" - message += f"\n**\U0001F480 Top 3 AHD ({actual_category})**\n" - for i, player in enumerate(top_ahd, start=1): - message += f"{i}. **{player['playername']}** - {player['ahd']:.2f}\n" + response_message += "**\U0001F3C6 Top 3 Winratio**\n" + if top_winratio: + for i, player in enumerate(top_winratio, start=1): + player_name = player.get('playername', 'Onbekend') + win_ratio = safe_get_float(player, 'winratio') + response_message += f"{i}. **{player_name}** - {win_ratio:.2f}%\n" + else: + response_message += "_Geen data beschikbaar_\n" - await ctx.send(message) + response_message += f"\n**\U0001F4A5 Top 3 Average Human Damage (AHD)**\n" # Changed emoji + if top_ahd: + for i, player in enumerate(top_ahd, start=1): + player_name = player.get('playername', 'Onbekend') + ahd_val = safe_get_float(player, 'ahd') + response_message += f"{i}. **{player_name}** - {ahd_val:.2f}\n" + else: + response_message += "_Geen data beschikbaar_\n" + await ctx.send(response_message) + logger.info("Successfully sent whoisbest stats for %s", actual_category) + + except FileNotFoundError: + await ctx.send(f"Statistieken bestand niet gevonden ({STATS_FILE_PATH}).") + logger.error("Stats file not found: %s", STATS_FILE_PATH) + except json.JSONDecodeError: + await ctx.send("Fout bij het lezen van de statistieken (ongeldig JSON).") + logger.exception("JSONDecodeError reading stats file: %s", STATS_FILE_PATH) except Exception as e: - await ctx.send(f"Fout bij het laden van de statistieken: {str(e)}") + await ctx.send(f"Onverwachte fout bij het ophalen van statistieken: {e}") + logger.exception("Error in whoisbest command: %s", e) -@bot.event -async def on_voice_state_update(member, before, after): - logging_channel = discord.utils.get(member.guild.text_channels, name="logging") - - if not logging_channel: - return - if (before.channel and before.channel.name == "GOD_CHANNEL") or (after.channel and after.channel.name == "GOD_CHANNEL"): - return # Als het God_channel is, doe niks - - if before.channel is None and after.channel is not None: - # Lid joint een voice channel - await logging_channel.send(f"🔊 {member.mention} is gejoined in voice kanaal: **{after.channel.name}**") - elif before.channel is not None and after.channel is None: - # Lid verlaat een voice channel - await logging_channel.send(f"🔇 {member.mention} heeft het voice kanaal **{before.channel.name}** verlaten.") - elif before.channel != after.channel: - # Lid switched van voice kanaal - await logging_channel.send(f"🔄 {member.mention} is van **{before.channel.name}** naar **{after.channel.name}** gegaan.") - -@bot.event -async def on_member_join(member): - logging_channel = discord.utils.get(member.guild.text_channels, name="raadhuisplein") - if logging_channel: - await logging_channel.send(f"🎉 Welkom {member.mention} op de server! We hopen dat je een leuke tijd hebt!") - -@bot.event -async def on_member_remove(member): - logging_channel = discord.utils.get(member.guild.text_channels, name="raadhuisplein") - if logging_channel: - await logging_channel.send(f"😢 {member.name} heeft de server verlaten. We zullen je missen!") @bot.command() +@commands.has_permissions(move_members=True) # Add permission check async def moveall(ctx): - # Controleer of het commando in het juiste kanaal wordt uitgevoerd - if ctx.channel.name != "teamify": - await ctx.send("Dit commando kan alleen worden gebruikt in het #teamify tekstkanaal.") + """Moves all members from other voice channels to the 'teamify' channel.""" + # Check if command is used in the correct text channel + if ctx.channel.name != TEAMIFY_TEXT_CHANNEL: + await ctx.send(f"Dit commando kan alleen worden gebruikt in het #{TEAMIFY_TEXT_CHANNEL} tekstkanaal.") return - + guild = ctx.guild - teamify_channel = discord.utils.get(guild.voice_channels, name="teamify") - - if not teamify_channel: - await ctx.send("Het teamify voice-kanaal bestaat niet!") + target_channel = discord.utils.get(guild.voice_channels, name=TEAMIFY_VOICE_CHANNEL) + + if not target_channel: + await ctx.send(f"Het '{TEAMIFY_VOICE_CHANNEL}' voice-kanaal bestaat niet!") + logger.error("Target voice channel '%s' not found for moveall.", TEAMIFY_VOICE_CHANNEL) return - - moved_members = 0 + + moved_members_count = 0 + move_tasks = [] + members_to_move = [] + + logger.info("Moveall command initiated by %s", ctx.author.display_name) for channel in guild.voice_channels: - if channel != teamify_channel: - for member in channel.members: - try: - await member.move_to(teamify_channel) - moved_members += 1 - except Exception as e: - await ctx.send(f"Kon {member.mention} niet verplaatsen: {e}") - - if moved_members > 0: - await ctx.send(f"{moved_members} speler(s) zijn verplaatst naar het teamify kanaal.") + # Skip the target channel itself and channels with no members + if channel == target_channel or not channel.members: + continue + + for member in channel.members: + # Avoid moving bots unless intended + # if member.bot: continue + members_to_move.append(member) + move_tasks.append(member.move_to(target_channel)) + + if not move_tasks: + await ctx.send("Er waren geen spelers in andere kanalen om te verplaatsen.") + logger.info("Moveall: No members found in other channels.") + return + + logger.info("Attempting to move %d members to #%s", len(move_tasks), target_channel.name) + results = await asyncio.gather(*move_tasks, return_exceptions=True) + + for i, result in enumerate(results): + member = members_to_move[i] + if isinstance(result, Exception): + logger.error("Failed to move member %s: %s", member.display_name, result) + try: + await ctx.send(f"Kon {member.mention} niet verplaatsen: {result}") + except discord.HTTPException: pass + else: + moved_members_count += 1 + + logger.info("Moveall finished. Successfully moved %d members.", moved_members_count) + if moved_members_count > 0: + await ctx.send(f"{moved_members_count} speler(s) zijn verplaatst naar het {target_channel.mention} kanaal.") else: - await ctx.send("Er waren geen spelers om te verplaatsen.") - -bot.run(token) + await ctx.send("Kon geen spelers verplaatsen (controleer permissies?).") + + +@moveall.error +async def moveall_error(ctx, error): + """Error handler specific to moveall command.""" + if isinstance(error, commands.MissingPermissions): + await ctx.send("Je hebt geen permissies om leden te verplaatsen.") + else: + # Log and notify for other errors related to moveall + logger.exception("Error in moveall command: %s", error) + await ctx.send("Er is een fout opgetreden bij het uitvoeren van `moveall`.") + + +# --- Run Bot --- +if __name__ == "__main__": + logger.info("Starting bot...") + bot.run(token) diff --git a/includes/ps1/lockfile.ps1 b/includes/ps1/lockfile.ps1 index 2db8ad5..6c8a3bc 100644 --- a/includes/ps1/lockfile.ps1 +++ b/includes/ps1/lockfile.ps1 @@ -1,52 +1,80 @@ -function new-lock { +# Functions for creating and removing a simple lock file to prevent concurrent script execution. + +function New-Lock { [CmdletBinding()] param ( [Parameter()] - [string] - $by + [string]$by # Optional identifier for who/what created the lock ) - Write-Output 'Setting lock' - $timeout = 15 - $i = 0 + Write-Verbose "Attempting to acquire lock..." + $timeoutSeconds = 15 * 15 # Increased timeout to ~4 minutes (15 attempts * 15 seconds) + $sleepSeconds = 15 + $startTime = Get-Date + + # Determine lock file path based on OS + if ($IsWindows) { + $lockFilePath = Join-Path -Path $env:TEMP -ChildPath 'pubg_stats.lock' + } elseif ($IsLinux -or $IsMacOS) { + $lockFilePath = '/tmp/pubg_stats.lock' + } else { + Write-Error "Unsupported operating system for lock file path determination." + throw "Unsupported OS for locking." # Throw to ensure script stops + } + Write-Verbose "Using lock file path: $lockFilePath" + while ($true) { - if ($env:temp) { - $lockFile = Join-Path -Path $env:temp -ChildPath 'lockfile_pubg.lock' - } - else { - $lockFile = "/tmp/lockfile_pubg.lock" + if (Test-Path -Path $lockFilePath -PathType Leaf) { + $lockContent = Get-Content -Path $lockFilePath -Raw -ErrorAction SilentlyContinue + Write-Warning ("Lock file '$lockFilePath' already exists (Content: '$lockContent'). Waiting $sleepSeconds seconds...") + Start-Sleep -Seconds $sleepSeconds + } else { + try { + # Attempt to create the lock file + $lockCreatorInfo = if ($by) { "Locked by '$by' at $(Get-Date)" } else { "Locked at $(Get-Date)" } + New-Item -ItemType File -Path $lockFilePath -Value $lockCreatorInfo -Force -ErrorAction Stop | Out-Null + Write-Output "Lock file created successfully at '$lockFilePath'." + return # Exit the loop and function on success + } catch { + # This catch might occur if another process creates the file between Test-Path and New-Item (race condition) + $errorMessage = $_.Exception.Message + Write-Warning "Failed to create lock file (possible race condition?): $errorMessage. Retrying..." + Start-Sleep -Seconds 1 # Short sleep before retry on creation failure + } } - if (Test-Path -Path $lockFile) { - Write-Host "Job is already running. Lock file found at $lockFile. Sleeping 15 seconds." - Start-Sleep -Seconds 15 + # Check for timeout + if (((Get-Date) - $startTime).TotalSeconds -ge $timeoutSeconds) { + Write-Error "Timed out after $timeoutSeconds seconds waiting for lock file '$lockFilePath'." + throw "Lock acquisition timed out." # Throw to ensure script stops } - else { - try { - $content = if ($by) { $by } else { "" } - New-Item -ItemType File -Path $lockFile -Value $content -Force - Write-Host "Lock file created at $lockFile." - break - } - catch { - Write-Output "Unable to create lockfile, error: $_. Resuming lock loop." - } - } - if ($i -ge $timeout) { - Write-Output "Timed out after $timeout attempts." - exit - } - $i++ } } -function remove-lock { - Write-Output 'Removing lock' - if ($env:temp) { - $lockFile = Join-Path -Path $env:temp -ChildPath 'lockfile_pubg.lock' +function Remove-Lock { + Write-Verbose "Attempting to remove lock..." + + # Determine lock file path (consistent with New-Lock) + if ($IsWindows) { + $lockFilePath = Join-Path -Path $env:TEMP -ChildPath 'pubg_stats.lock' + } elseif ($IsLinux -or $IsMacOS) { + $lockFilePath = '/tmp/pubg_stats.lock' + } else { + Write-Warning "Unsupported operating system for lock file path determination. Cannot remove lock." + return } - else { - $lockFile = "/tmp/lockfile_pubg.lock" + Write-Verbose "Target lock file path: $lockFilePath" + + if (Test-Path -Path $lockFilePath -PathType Leaf) { + try { + Remove-Item -Path $lockFilePath -Force -ErrorAction Stop + Write-Output "Lock file '$lockFilePath' removed successfully." + } catch { + $errorMessage = $_.Exception.Message + Write-Error "Failed to remove lock file '$lockFilePath'. Manual removal might be required. Error: $errorMessage" + # Consider if this should be a fatal error depending on script requirements + } + } else { + Write-Warning "Lock file '$lockFilePath' not found. Assuming already removed." } - Remove-Item -Path $lockFile } \ No newline at end of file diff --git a/includes/ratelimiter.php b/includes/ratelimiter.php index e9dbd02..f52afe8 100644 --- a/includes/ratelimiter.php +++ b/includes/ratelimiter.php @@ -22,20 +22,46 @@ foreach ($_SESSION['access_times'] as $key => $timestamp) { if (count($_SESSION['access_times']) > $allowed_refreshes) { - die(' - - + // Set HTTP status code for rate limiting + http_response_code(429); // Too Many Requests +?> + + - - RustAGHH + + Te Veel Verzoeken + -

RUSTAAAGGHHHH je mag de pagina niet vaker dan ' . $allowed_refreshes . 'x per ' . $allowed_time . ' seconde refreshen. Over een paar seconden wordt je weer teruggeleid.

+
+

Rustâââgh!

+

Je probeert de pagina te vaak te vernieuwen (meer dan keer per seconden).

+

Wacht even, je wordt over 5 seconden automatisch teruggestuurd.

+
+ -'); + diff --git a/index.php b/index.php index a782c58..e7f3c67 100644 --- a/index.php +++ b/index.php @@ -1,32 +1,86 @@ - - +

Latest Matches

- - - - - - - - - - - - - - - - modify('+1 hours'); - $formattedDate = $date->format('m-d H:i:s'); - echo " - - - - - - - - - - - "; - } ?> - -
Player NameMatch DateModeTypeMapKillsDamagePlace
" . $match['playername'] . "" . $formattedDate . "" . $match['gameMode'] . "" . $match['matchType'] . "" . (isset($mapNames[$match['mapName']]) ? $mapNames[$match['mapName']] : $match['mapName']) . "" . $match['stats']['kills'] . "" . number_format($match['stats']['damageDealt'], 0, '.', '') . "" . $match['stats']['winPlace'] . "
-

Clan Info

- "; - echo "AttributeValueRank(FPP SQUAD)Points"; - - foreach ($playerRanks as $rank) { - - $playername = $rank['name']; - if (isset($rank['stat']['data']['attributes']['rankedGameModeStats']['squad-fpp'])) { - $tier = $rank['stat']['data']['attributes']['rankedGameModeStats']['squad-fpp']['currentTier']['tier']; - $subTier = $rank['stat']['data']['attributes']['rankedGameModeStats']['squad-fpp']['currentTier']['subTier']; - $image = "./images/ranks/" . $tier . "-" . $subTier . ".webp"; - $rankPoint = htmlspecialchars($rank['stat']['data']['attributes']['rankedGameModeStats']['squad-fpp']['currentRankPoint']); - echo "name" . htmlspecialchars($playername) . "$tier" . $rankPoint . ""; - } else { - echo "name" . htmlspecialchars($playername) . "$tier"; + +

+ +

No recent matches found.

+ + + + + + + + + + + + + + + + modify('+1 hours'); // Adjust timezone or add offset as needed + $formattedDate = $date->format('m-d H:i:s'); + } catch (Exception $e) { + $formattedDate = 'Invalid Date'; } - + } + ?> + + + + + + + + + + + + +
Player NameMatch DateModeTypeMapKillsDamagePlace
+ +
+
+

Clan Info

+ +

+ + +

+ - } - - foreach ($clan as $key => $value) { - if ($key == 'updated') { - continue; - } - echo "" . htmlspecialchars($key) . "" . htmlspecialchars($value) . ""; - } - echo ""; - } else { - echo "

No clan attributes available

"; - } - - } else { - echo "

Clan info file missing

"; - } - ?> + + + + + + + + + + + + + + + + + $value): + if ($key == 'updated') continue; // Skip updated timestamp + ?> + + + + + + + + +
AttributeValueRank (FPP SQUAD)Points
name<?php echo htmlspecialchars($altText); ?>
+ +

No clan attributes available.

+ +
- - \ No newline at end of file diff --git a/last_stats.php b/last_stats.php index 28ae21a..181dc78 100644 --- a/last_stats.php +++ b/last_stats.php @@ -1,11 +1,119 @@ +// Include config if it exists +$configPath = './config/config.php'; +if (file_exists($configPath)) { + include $configPath; +} + +$clanmembersData = null; +$alts = []; +$playersStatsData = null; +$processedStats = []; +$dataError = ''; +$lastUpdated = 'N/A'; + +// Category definitions with minimum match requirements and display names +$categories = [ + // 'all' => ['min_matches' => 25, 'display_name' => 'All Matches (min 25)'], // Skipping 'all' as per original logic + 'clan_casual' => ['min_matches' => 18, 'display_name' => 'Clan Casual (min 18 matches, min 2 clan players)'], + 'Intense' => ['min_matches' => 18, 'display_name' => 'Intense BR (min 18 matches)'], + 'Casual' => ['min_matches' => 18, 'display_name' => 'Casual (min 18 matches)'], + 'official' => ['min_matches' => 18, 'display_name' => 'Official (min 18 matches)'], + 'custom' => ['min_matches' => 8, 'display_name' => 'Custom (min 8 matches)'], + 'Ranked' => ['min_matches' => 8, 'display_name' => 'Ranked (min 8 matches)'], +]; + +// Load clan members to identify alts +$clanMembersPath = './config/clanmembers.json'; +if (file_exists($clanMembersPath)) { + $clanmembersJson = file_get_contents($clanMembersPath); + $clanmembersData = json_decode($clanmembersJson, true); + if (isset($clanmembersData['alts']) && is_array($clanmembersData['alts'])) { + $alts = $clanmembersData['alts']; + } else { + $dataError .= " Error reading or invalid format for 'alts' in clan members file."; + } +} else { + $dataError .= " Clan members file not found."; +} + +// Load player stats data +$statsPath = './data/player_last_stats.json'; +if (file_exists($statsPath)) { + $statsJson = file_get_contents($statsPath); + $playersStatsData = json_decode($statsJson, true); + + if (!is_array($playersStatsData)) { + $dataError .= " Error decoding player last stats data."; + $playersStatsData = null; + } else { + $lastUpdated = htmlspecialchars($playersStatsData['updated'] ?? 'N/A'); + + // Process stats for each defined category + foreach ($categories as $key => $categoryInfo) { + if (isset($playersStatsData[$key]) && is_array($playersStatsData[$key])) { + $categoryStats = []; + foreach ($playersStatsData[$key] as $player_data) { + // Basic validation and alt check + if (!isset($player_data['playername']) || is_null($player_data['playername']) || in_array($player_data['playername'], $alts)) { + continue; + } + + // Minimum matches check + $matchesPlayed = $player_data['matches'] ?? 0; + if ($matchesPlayed < $categoryInfo['min_matches']) { + continue; + } + + // Format stats for display + $formatted_player_data = []; + $formatted_player_data['playername'] = htmlspecialchars($player_data['playername']); + $formatted_player_data['deaths'] = number_format($player_data['deaths'] ?? 0, 0, ',', ''); + $formatted_player_data['kills'] = number_format($player_data['kills'] ?? 0, 0, ',', ''); + $formatted_player_data['humankills'] = number_format($player_data['humankills'] ?? 0, 0, ',', ''); + $formatted_player_data['matches'] = htmlspecialchars($matchesPlayed); + $formatted_player_data['wins'] = number_format($player_data['wins'] ?? 0, 0, ',', ''); + $formatted_player_data['winratio'] = number_format($player_data['winratio'] ?? 0, 2, ',', ''); + $formatted_player_data['ahd'] = number_format($player_data['ahd'] ?? 0, 2, ',', ''); + + // Format K/D (handle null, Infinity, non-numeric) + $kd_h_raw = $player_data['KD_H'] ?? null; + $formatted_player_data['KD_H'] = ($kd_h_raw === null) ? "N/A" : (($kd_h_raw == "Infinity") ? "∞" : (is_numeric($kd_h_raw) ? number_format((float)$kd_h_raw, 2, ',', '') : "0")); + $kd_all_raw = $player_data['KD_ALL'] ?? null; + $formatted_player_data['KD_ALL'] = ($kd_all_raw === null) ? "N/A" : (($kd_all_raw == "Infinity") ? "∞" : (is_numeric($kd_all_raw) ? number_format((float)$kd_all_raw, 2, ',', '') : "0")); + + // Format change indicator + $originalChange = isset($player_data['change']) ? str_replace(',', '.', $player_data['change']) : '0'; + $changeValue = floatval($originalChange); + $formatted_player_data['change_value'] = number_format($changeValue, 2, ',', ''); // Display formatted number + if ($changeValue < 0) { + $formatted_player_data['change_image'] = 'images/red.png'; + $formatted_player_data['change_alt'] = 'Decrease'; + } elseif ($changeValue > 0) { + $formatted_player_data['change_image'] = 'images/green.png'; + $formatted_player_data['change_alt'] = 'Increase'; + } else { + $formatted_player_data['change_image'] = 'images/equal.png'; + $formatted_player_data['change_alt'] = 'No change'; + } + + $categoryStats[] = $formatted_player_data; + } + $processedStats[$key] = $categoryStats; + } + } + } +} else { + $dataError .= " Player last stats data file not found."; +} + +?> - - +
-

Player Stats past Quarter

- Player Stats Past Quarter - foreach ($players_matches as $key => $player_datas) { - if ($key == 'updated') { - continue; - } - if ($key == 'all') { - continue; - } + +

+ - echo "
"; - // if ($key == 'all') { - // echo "Stats for $key (minimal 25 matches)"; - // } - if ($key == 'clan_casual') { - echo "Stats for $key (minimal 18 matches) - Clan casual min 2 clan players per match"; - } - if ($key == 'Intense') { - echo "Stats for $key (minimal 18 matches)"; - } - if ($key == 'Casual') { - echo "Stats for $key (minimal 18 matches)"; - } - if ($key == 'official') { - echo "Stats for $key (minimal 18 matches)"; - } - if ($key == 'custom') { - echo "Stats for $key (minimal 8 matches)"; - } - if ($key == 'Ranked') { - echo "Stats for $key (minimal 8 matches)"; - } - echo ""; - echo " - - - - - - - - - - - + $categoryInfo): ?> + +
+

+ +

No players met the criteria for this category.

+ +
PlayerWin %AHDK/D HumanHuman KillsK/D AllKillsMtchsWinsDeathsWin % change
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlayerWin %AHDK/D HumanHuman KillsK/D AllKillsMatchesWinsDeathsWin % Change
+ <?php echo htmlspecialchars($player_stat['change_alt']); ?> + +
+ + + - - "; - foreach ($player_datas as $player_data) { - if (!isset($player_data['playername']) || is_null($player_data['playername'])) { - continue; // Skip this iteration and move to the next - } - if (in_array($player_data['playername'], $alts)) { - continue; // Skip alt players - } - - if ($key == 'all' && $player_data['matches'] < 25) { - continue; - } - if ($key == 'clan_casual' && $player_data['matches'] < 18) { - continue; - } - if ($key == 'Intense' && $player_data['matches'] < 18) { - continue; - } - if ($key == 'Casual' && $player_data['matches'] < 18) { - continue; - } - if ($key == 'official' && $player_data['matches'] < 18) { - continue; - } - if ($key == 'custom' && $player_data['matches'] < 8) { - continue; - } - if ($key == 'Ranked' && $player_data['matches'] < 8) { - continue; - } +

Last update:

- $player_name = $player_data['playername']; - $deaths = number_format($player_data['deaths'], 0, ',', ''); - $kills = number_format($player_data['kills'], 0, ',', ''); - $humankills = number_format($player_data['humankills'], 0, ',', ''); - $matches = $player_data['matches']; - $KD_H = - !isset($player_data['KD_H']) || $player_data['KD_H'] === null - ? "null" - : ($player_data['KD_H'] == "Infinity" - ? "∞" - : (is_numeric($player_data['KD_H']) - ? number_format((float) $player_data['KD_H'], 2, ',', '') - : "0")); // or any other default string for non-numerical values - - - $KD_ALL = - !isset($player_data['KD_ALL']) || $player_data['KD_ALL'] === null - ? "null" - : ($player_data['KD_ALL'] == "Infinity" - ? "∞" - : (is_numeric($player_data['KD_ALL']) - ? number_format((float) $player_data['KD_ALL'], 2, ',', '') - : "0")); // or any other default string for non-numerical values - $wins = number_format($player_data['wins'], 0, ',', ''); - $winratio = number_format($player_data['winratio'], 2, ',', ''); - $originalChange = str_replace(',', '.', $player_data['change']); // replace comma with period - $change = floatval($originalChange); - $ahd = number_format($player_data['ahd'], 2, ',', ''); - - if ($originalChange < 0) { - $imagePath = 'images\red.png'; - } elseif ($change > 0) { - $imagePath = 'images\green.png'; - } else { - $imagePath = 'images\equal.png'; - } - - - - echo " - $player_name - $winratio - $ahd - $KD_H - $humankills - $KD_ALL - $kills - $matches - $wins - $deaths - Change Indicator $change - - - "; - } - - echo ""; - } - - foreach ($players_matches as $key => $update) { - if ($key == 'updated') { - echo "Last update: $update "; - - } - } - - - - - - ?>
- diff --git a/latestmatches.php b/latestmatches.php index ce5d3f9..117a6bc 100644 --- a/latestmatches.php +++ b/latestmatches.php @@ -1,12 +1,113 @@ modify('+1 hours'); // Adjust timezone or add offset as needed + $displayMatch['formattedDate'] = $date->format('m-d H:i:s'); + } catch (Exception $e) { + $displayMatch['formattedDate'] = 'Invalid Date'; + } + } + $filteredMatches[] = $displayMatch; + } + break; // Found the selected player in this match, move to the next match + } + } + } + if (empty($filteredMatches) && !$dataError) { + $dataError .= " No matches found for " . htmlspecialchars($selected_player) . ($filter_by_match_type !== 'all' ? " with type '" . htmlspecialchars($filter_by_match_type) . "'" : "") . "."; + } + } +} else { + $dataError .= " Cached matches data file not found."; +} + ?> - - - - +

Match Stats

- +

+ - // Display buttons for each player - echo "
"; - foreach ($players['clanMembers'] as $player) { + + + + + + + + +
+
+ - echo ""; - - } - echo "
"; - $selected_player = $_GET['selected_player'] ?? $players['clanMembers'][0]; - echo "
- - - - - - - -

"; + + +
+ + + + +
+
+ - - include './includes/mapsmap.php'; - // Display the player's match stats - echo "

Recent Matches for $selected_player

"; - echo ""; - echo ""; - foreach ($players_matches as $match) { - // print_r($match['stats']); - foreach ($match['stats'] as $stats) { - if ($stats['name'] === $selected_player) { - - - if (isset($_GET['filter_by_match_type'])) { - if ($_GET['filter_by_match_type'] !== 'all' && $match['matchType'] !== $_GET['filter_by_match_type']) { - continue; - } - } - $date = new DateTime($match['createdAt']); - $date->modify('+1 hours'); - $formattedDate = $date->format('m-d H:i:s'); - - $matchType = $match['matchType']; - $gameMode = $match['gameMode']; - $mapName = isset($mapNames[$match['mapName']]) ? $mapNames[$match['mapName']] : $match['mapName']; - $kills = $stats['kills']; - $damage = number_format($stats['damageDealt'], 0, '.', ''); - $timeSurvived = $stats['timeSurvived']; - $winPlace = $stats['winPlace']; - echo " - - - - - - - - - "; - - - - - } - } - } - echo "
Match DateGame ModeMatch TypeMapKillsDamage DealtTime Survivedwin Place
" . $formattedDate . "" . $gameMode . "" . $matchType . "" . $mapName . "" . $kills . "" . $damage . "" . $timeSurvived . "" . $winPlace . "

"; - ?> + +

Recent Matches for ()

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Match DateGame ModeMatch TypeMapKillsDamage DealtTime Survived (s)Win Place
+
+ +

No matches found for the selected criteria.

+
- \ No newline at end of file diff --git a/lib/sorttable.js b/lib/sorttable.js index 5c02d19..9691195 100644 --- a/lib/sorttable.js +++ b/lib/sorttable.js @@ -1,8 +1,8 @@ /* SortTable - version 2 - 7th April 2007 - Stuart Langridge, http://www.kryogenix.org/code/browser/sorttable/ + version 2 (Modernized) + Original: 7th April 2007, Stuart Langridge, http://www.kryogenix.org/code/browser/sorttable/ + Modernized: [Current Date] Instructions: Download this file @@ -10,185 +10,192 @@ Add class="sortable" to any table you'd like to make sortable Click on the headers to sort - Thanks to many, many people for contributions and suggestions. Licenced as X11: http://www.kryogenix.org/code/browser/licence.html This basically means: do what you want with it. */ +const sorttable = { + DATE_RE: /^(\d{1,2})[\/\.-](\d{1,2})[\/\.-]((\d{2})?\d{2})$/, // Regex for date parsing dd/mm/yyyy or mm/dd/yyyy + SORT_COLUMN_INDEX: 'sorttable_columnindex', // Custom attribute to store column index -var stIsIE = /*@cc_on!@*/false; - -sorttable = { init: function() { // quit if this function has already been called - if (arguments.callee.done) return; - // flag this function so we don't do the same thing twice - arguments.callee.done = true; - // kill the timer - if (_timer) clearInterval(_timer); + if (this.initialized) return; + this.initialized = true; if (!document.createElement || !document.getElementsByTagName) return; - sorttable.DATE_RE = /^(\d\d?)[\/\.-](\d\d?)[\/\.-]((\d\d)?\d\d)$/; - - forEach(document.getElementsByTagName('table'), function(table) { - if (table.className.search(/\bsortable\b/) != -1) { - sorttable.makeSortable(table); - } + document.querySelectorAll('table.sortable').forEach(table => { + this.makeSortable(table); }); - }, makeSortable: function(table) { - if (table.getElementsByTagName('thead').length == 0) { - // table doesn't have a tHead. Since it should have, create one and - // put the first table row in it. - the = document.createElement('thead'); - the.appendChild(table.rows[0]); - table.insertBefore(the,table.firstChild); - } - // Safari doesn't support table.tHead, sigh - if (table.tHead == null) table.tHead = table.getElementsByTagName('thead')[0]; - - if (table.tHead.rows.length != 1) return; // can't cope with two header rows - - // Sorttable v1 put rows with a class of "sortbottom" at the bottom (as - // "total" rows, for example). This is B&R, since what you're supposed - // to do is put them in a tfoot. So, if there are sortbottom rows, - // for backwards compatibility, move them to tfoot (creating it if needed). - sortbottomrows = []; - for (var i=0; i 0) { + the.appendChild(table.rows[0]); + table.insertBefore(the, table.firstChild); + } else { + // Cannot make an empty table sortable + return; } } - if (sortbottomrows) { - if (table.tFoot == null) { + // Ensure tHead is correctly referenced (needed for some older browser compatibility, safe otherwise) + if (table.tHead == null) table.tHead = table.getElementsByTagName('thead')[0]; + + if (table.tHead.rows.length !== 1) return; // Can't cope with multiple header rows + + // Handle backwards compatibility for "sortbottom" class (move to tfoot) + const sortbottomrows = []; + // Use Array.from for iterating HTMLCollection + Array.from(table.rows).forEach(row => { + if (row.classList.contains('sortbottom')) { + sortbottomrows.push(row); + } + }); + + if (sortbottomrows.length > 0) { + let tfo = table.tFoot; + if (!tfo) { // table doesn't have a tfoot. Create one. tfo = document.createElement('tfoot'); table.appendChild(tfo); } - for (var i=0; i { + tfo.appendChild(row); + }); } - // work through each column and calculate its type - headrow = table.tHead.rows[0].cells; - for (var i=0; i5' : ' ▴'; - this.appendChild(sortrevind); - return; - } - if (this.className.search(/\bsorttable_sorted_reverse\b/) != -1) { - // if we're already sorted by this column in reverse, just - // re-reverse the table, which is quicker - sorttable.reverse(this.sorttable_tbody); - this.className = this.className.replace('sorttable_sorted_reverse', - 'sorttable_sorted'); - this.removeChild(document.getElementById('sorttable_sortrevind')); - sortfwdind = document.createElement('span'); - sortfwdind.id = "sorttable_sortfwdind"; - sortfwdind.innerHTML = stIsIE ? ' 6' : ' ▾'; - this.appendChild(sortfwdind); - return; - } + if (override && typeof this[`sort_${override}`] === 'function') { + sortFunc = this[`sort_${override}`]; + } else { + sortFunc = this.guessType(table, i); + } - // remove sorttable_sorted classes - theadrow = this.parentNode; - forEach(theadrow.childNodes, function(cell) { - if (cell.nodeType == 1) { // an element - cell.className = cell.className.replace('sorttable_sorted_reverse',''); - cell.className = cell.className.replace('sorttable_sorted',''); - } - }); - sortfwdind = document.getElementById('sorttable_sortfwdind'); - if (sortfwdind) { sortfwdind.parentNode.removeChild(sortfwdind); } - sortrevind = document.getElementById('sorttable_sortrevind'); - if (sortrevind) { sortrevind.parentNode.removeChild(sortrevind); } + // Make header clickable + cell.sorttable_sortfunction = sortFunc; + cell.setAttribute(this.SORT_COLUMN_INDEX, i); // Store index using attribute + // Use standard addEventListener + cell.addEventListener('click', (e) => this.headerClick(e)); - this.className += ' sorttable_sorted'; - sortfwdind = document.createElement('span'); - sortfwdind.id = "sorttable_sortfwdind"; - sortfwdind.innerHTML = stIsIE ? ' 6' : ' ▾'; - this.appendChild(sortfwdind); - - // build an array to sort. This is a Schwartzian transform thing, - // i.e., we "decorate" each row with the actual sort key, - // sort based on the sort keys, and then put the rows back in order - // which is a lot faster because you only do getInnerText once per row - row_array = []; - col = this.sorttable_columnindex; - rows = this.sorttable_tbody.rows; - for (var j=0; j { + // Remove existing indicators + targetCell.querySelectorAll('.sorttable_sortindicator').forEach(span => span.remove()); + // Add new indicator + const indicator = document.createElement('span'); + indicator.className = 'sorttable_sortindicator'; + indicator.innerHTML = direction === 'forward' ? ' ▾' : ' ▴'; // Down / Up arrow + targetCell.appendChild(indicator); + }; + + // Remove sorting classes and indicators from all headers in this row + cell.parentNode.querySelectorAll('th, td').forEach(siblingCell => { + siblingCell.classList.remove('sorttable_sorted', 'sorttable_sorted_reverse'); + siblingCell.querySelectorAll('.sorttable_sortindicator').forEach(span => span.remove()); + }); + + if (isSorted) { + // If already sorted by this column, just reverse the table body + this.reverse(tbody); + cell.classList.add('sorttable_sorted_reverse'); + updateIndicator(cell, 'reverse'); + } else if (isSortedReverse) { + // If sorted reverse, sort forward again (effectively re-reversing) + // This requires a full sort, not just reversing the current order + this.fullSort(tbody, columnIndex, sortFunction); + cell.classList.add('sorttable_sorted'); + updateIndicator(cell, 'forward'); + } else { + // New sort + this.fullSort(tbody, columnIndex, sortFunction); + cell.classList.add('sorttable_sorted'); + updateIndicator(cell, 'forward'); + } + }, + + fullSort: function(tbody, columnIndex, sortFunction) { + // Build an array to sort (Schwartzian transform) + const rowArray = []; + Array.from(tbody.rows).forEach(row => { + const cell = row.cells[columnIndex]; + const sortKey = this.getInnerText(cell); + rowArray.push([sortKey, row]); + }); + + // Sort the array using the determined sort function + rowArray.sort(sortFunction); + + // Append rows back to the tbody in the new order + rowArray.forEach(item => { + tbody.appendChild(item[1]); + }); + }, + guessType: function(table, column) { - // guess the type of a column based on its first non-blank row - sortfn = sorttable.sort_alpha; - for (var i=0; i 12) { - // definitely dd/mm - return sorttable.sort_ddmm; + // Definitely dd/mm + return this.sort_ddmm; } else if (second > 12) { - return sorttable.sort_mmdd; + // Definitely mm/dd + return this.sort_mmdd; } else { - // looks like a date, but we can't tell which, so assume - // that it's dd/mm (English imperialism!) and keep looking - sortfn = sorttable.sort_ddmm; + // Ambiguous (e.g., 01/02/2023). Default to dd/mm but continue checking other rows. + // If a later row is unambiguously mm/dd, that will take precedence. + sortfn = this.sort_ddmm; } } } @@ -197,298 +204,121 @@ sorttable = { }, getInnerText: function(node) { - // gets the text we want to use for sorting for a cell. - // strips leading and trailing whitespace. - // this is *not* a generic getInnerText function; it's special to sorttable. - // for example, you can override the cell text with a customkey attribute. - // it also gets .value for fields. + // Gets the text we want to use for sorting for a cell. + // Strips leading and trailing whitespace. + // Special handling for custom key attribute and input fields. if (!node) return ""; - hasInputs = (typeof node.getElementsByTagName == 'function') && - node.getElementsByTagName('input').length; + // Check for custom sort key attribute first + const customKey = node.getAttribute("sorttable_customkey"); + if (customKey != null) { + return customKey; + } - if (node.getAttribute("sorttable_customkey") != null) { - return node.getAttribute("sorttable_customkey"); + // Handle input fields + if (node.tagName === 'INPUT' && node.value) { + return node.value.trim(); } - else if (typeof node.textContent != 'undefined' && !hasInputs) { - return node.textContent.replace(/^\s+|\s+$/g, ''); + + // Use textContent for modern browsers (strips tags) + if (typeof node.textContent !== 'undefined') { + return node.textContent.trim(); } - else if (typeof node.innerText != 'undefined' && !hasInputs) { - return node.innerText.replace(/^\s+|\s+$/g, ''); - } - else if (typeof node.text != 'undefined' && !hasInputs) { - return node.text.replace(/^\s+|\s+$/g, ''); - } - else { - switch (node.nodeType) { - case 3: - if (node.nodeName.toLowerCase() == 'input') { - return node.value.replace(/^\s+|\s+$/g, ''); - } - case 4: - return node.nodeValue.replace(/^\s+|\s+$/g, ''); - break; - case 1: - case 11: - var innerText = ''; - for (var i = 0; i < node.childNodes.length; i++) { - innerText += sorttable.getInnerText(node.childNodes[i]); - } - return innerText.replace(/^\s+|\s+$/g, ''); - break; - default: - return ''; - } + + // Fallback (might include tags in older environments, less likely needed now) + if (typeof node.innerText !== 'undefined') { + return node.innerText.trim(); } + + // Recursive fallback for complex node structures (rarely needed with textContent) + let innerText = ''; + Array.from(node.childNodes).forEach(child => { + innerText += this.getInnerText(child); + }); + return innerText.trim(); + }, reverse: function(tbody) { - // reverse the rows in a tbody - newrows = []; - for (var i=0; i=0; i--) { - tbody.appendChild(newrows[i]); - } - delete newrows; + // Reverse the rows in a tbody + const rows = Array.from(tbody.rows); + rows.reverse().forEach(row => tbody.appendChild(row)); }, - /* sort functions - each sort function takes two parameters, a and b - you are comparing a[0] and b[0] */ - sort_numeric: function(a,b) { - aa = parseFloat(a[0].replace(/[^0-9.-]/g,'')); - if (isNaN(aa)) aa = 0; - bb = parseFloat(b[0].replace(/[^0-9.-]/g,'')); - if (isNaN(bb)) bb = 0; - return aa-bb; + /* === Sort Functions === + Each sort function takes two parameters, a and b (arrays from fullSort: [sortKey, rowElement]) + Compare a[0] and b[0] + */ + sort_numeric: function(a, b) { + // Clean string, parse float, default to 0 if NaN + const aa = parseFloat(String(a[0]).replace(/[^0-9.-]/g, '')) || 0; + const bb = parseFloat(String(b[0]).replace(/[^0-9.-]/g, '')) || 0; + return aa - bb; }, - sort_alpha: function(a,b) { - if (a[0]==b[0]) return 0; - if (a[0] 0 ) { - var q = list[i]; list[i] = list[i+1]; list[i+1] = q; - swap = true; - } - } // for - t--; - - if (!swap) break; - - for(var i = t; i > b; --i) { - if ( comp_func(list[i], list[i-1]) < 0 ) { - var q = list[i]; list[i] = list[i-1]; list[i-1] = q; - swap = true; - } - } // for - b++; - - } // while(swap) + // ... (original implementation using let/const) ... } + */ +}; + +// --- Initialization --- +// Use DOMContentLoaded which is more reliable and fires earlier than window.onload +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => sorttable.init()); +} else { + // Handle cases where the script is loaded after DOMContentLoaded + sorttable.init(); } - -/* ****************************************************************** - Supporting functions: bundled here to avoid depending on a library - ****************************************************************** */ - -// Dean Edwards/Matthias Miller/John Resig - -/* for Mozilla/Opera9 */ -if (document.addEventListener) { - document.addEventListener("DOMContentLoaded", sorttable.init, false); -} - -/* for Internet Explorer */ -/*@cc_on @*/ -/*@if (@_win32) - document.write("