// ==UserScript==
// @name Autodarts Tournament Assistant
// @namespace https://github.com/thomasasen/autodarts_local_tournament
// @version 0.3.5
// @description Local tournament manager for play.autodarts.io (KO, Liga, Gruppen + KO)
// @author Thomas Asen
// @license MIT
// @match *://play.autodarts.io/*
// @run-at document-start
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect cdn.jsdelivr.net
// @connect raw.githubusercontent.com
// @connect api.autodarts.io
// @downloadURL https://raw.githubusercontent.com/thomasasen/autodarts_local_tournament/main/dist/autodarts-tournament-assistant.user.js
// @updateURL https://raw.githubusercontent.com/thomasasen/autodarts_local_tournament/main/dist/autodarts-tournament-assistant.meta.js
// ==/UserScript==
(function () {
"use strict";
const RUNTIME_GUARD_KEY = "__ATA_RUNTIME_BOOTSTRAPPED";
const RUNTIME_GLOBAL_KEY = "__ATA_RUNTIME";
const APP_VERSION = "0.3.5";
const STORAGE_KEY = "ata:tournament:v1";
const STORAGE_SCHEMA_VERSION = 4;
const STORAGE_KO_MIGRATION_BACKUPS_KEY = "ata:tournament:ko-migration-backups:v2";
const SAVE_DEBOUNCE_MS = 150;
const UI_HOST_ID = "ata-ui-host";
const TOGGLE_EVENT = "ata:toggle-request";
const READY_EVENT = "ata:ready";
const LOADER_GUARD_KEY = "__ATA_LOADER_BOOTSTRAPPED";
const LOADER_MENU_ITEM_ID = "ata-loader-menu-item";
const API_PROVIDER = "api.autodarts.io";
const API_GS_BASE = `https://${API_PROVIDER}/gs/v0`;
const API_AS_BASE = `https://${API_PROVIDER}/as/v0`;
const API_AUTH_BASE = `https://${API_PROVIDER}/auth/v1`;
const API_AUTH_CLIENT_ID = "autodarts-play";
const API_SYNC_INTERVAL_MS = 2500;
const API_AUTH_NOTICE_THROTTLE_MS = 15000;
const API_REQUEST_TIMEOUT_MS = 12000;
const REPO_BLOB_BASE_URL = "https://github.com/thomasasen/autodarts_local_tournament/blob/main";
const README_BASE_URL = "https://github.com/thomasasen/autodarts_local_tournament/blob/main/README.md";
const README_SETTINGS_URL = `${README_BASE_URL}#einstellungen`;
const README_INFO_SYMBOLS_URL = `${README_BASE_URL}#info-symbole`;
const README_TOURNAMENT_MODES_URL = `${README_BASE_URL}#turniermodi`;
const README_TOURNAMENT_CREATE_URL = `${README_BASE_URL}#turnier-anlegen`;
const README_API_AUTOMATION_URL = `${README_BASE_URL}#api-halbautomatik`;
const DRA_GUI_RULES_DOC_URL = `${REPO_BLOB_BASE_URL}/docs/dra-regeln-gui.md`;
const DRA_GUI_RULE_MODE_FORMATS_URL = `${DRA_GUI_RULES_DOC_URL}#dra-gui-rule-mode-formats`;
const DRA_GUI_RULE_OPEN_DRAW_URL = `${DRA_GUI_RULES_DOC_URL}#dra-gui-rule-open-draw`;
const DRA_GUI_RULE_DRAW_LOCK_URL = `${DRA_GUI_RULES_DOC_URL}#dra-gui-rule-draw-lock`;
const DRA_GUI_RULE_PARTICIPANT_LIMITS_URL = `${DRA_GUI_RULES_DOC_URL}#dra-gui-rule-participant-limits`;
const DRA_GUI_RULE_BYE_URL = `${DRA_GUI_RULES_DOC_URL}#dra-gui-rule-bye`;
const DRA_GUI_RULE_TIE_BREAK_URL = `${DRA_GUI_RULES_DOC_URL}#dra-gui-rule-tie-break`;
const DRA_GUI_RULE_CHECKLIST_URL = `${DRA_GUI_RULES_DOC_URL}#dra-gui-rule-checklist`;
const USERSCRIPT_DOWNLOAD_URL = "https://raw.githubusercontent.com/thomasasen/autodarts_local_tournament/main/dist/autodarts-tournament-assistant.user.js";
const USERSCRIPT_UPDATE_URL = "https://raw.githubusercontent.com/thomasasen/autodarts_local_tournament/main/dist/autodarts-tournament-assistant.meta.js";
const USERSCRIPT_LOADER_URL = "https://github.com/thomasasen/autodarts_local_tournament/raw/refs/heads/main/installer/Autodarts%20Tournament%20Assistant%20Loader.user.js";
const UPDATE_STATUS_STORAGE_KEY = "ata:update-status:v1";
const UPDATE_CHECK_TTL_MS = 60 * 60 * 1000;
const UPDATE_AUTO_CHECK_INTERVAL_MS = 15 * 60 * 1000;
const UPDATE_CACHE_BUST_PARAM = "_ata_ts";
const BRACKETS_VIEWER_CSS = "https://cdn.jsdelivr.net/npm/brackets-viewer@1.9.0/dist/brackets-viewer.min.css";
const BRACKETS_VIEWER_JS = "https://cdn.jsdelivr.net/npm/brackets-viewer@1.9.0/dist/brackets-viewer.min.js";
const I18NEXT_JS = "https://cdn.jsdelivr.net/npm/i18next@23.16.8/dist/umd/i18next.min.js";
const ATA_UI_MAIN_CSS = ` :host {
--ata-space-1: 4px;
--ata-space-2: 8px;
--ata-space-3: 12px;
--ata-space-4: 16px;
--ata-space-5: 20px;
--ata-space-6: 24px;
--ata-radius-sm: 6px;
--ata-radius-md: 10px;
--ata-radius-lg: 16px;
--ata-shadow-lg: 0 18px 52px rgba(3, 8, 23, 0.52);
--ata-font-body: "Open Sans", "Segoe UI", Tahoma, sans-serif;
--ata-font-head: "Audiowide", "Open Sans", "Segoe UI", Tahoma, sans-serif;
--ata-color-bg: #162d56;
--ata-color-bg-panel: #2c326d;
--ata-color-bg-panel-2: #1f3f71;
--ata-color-bg-soft: rgba(255, 255, 255, 0.1);
--ata-color-text: #f4f7ff;
--ata-color-muted: rgba(232, 237, 255, 0.74);
--ata-color-border: rgba(255, 255, 255, 0.2);
--ata-color-accent: #5ad299;
--ata-color-danger: #fc8181;
--ata-color-focus: #ffd34f;
--ata-control-bg: rgba(16, 30, 62, 0.72);
--ata-control-bg-hover: rgba(23, 40, 82, 0.82);
--ata-control-bg-disabled: rgba(17, 27, 49, 0.55);
--ata-control-border: rgba(181, 201, 243, 0.36);
--ata-control-border-strong: rgba(210, 223, 255, 0.62);
--ata-z-overlay: 2147483000;
color: var(--ata-color-text);
font-family: var(--ata-font-body);
font-size: 19px;
line-height: 1.4;
color-scheme: dark;
}
.ata-root {
position: fixed;
inset: 0;
z-index: var(--ata-z-overlay);
display: grid;
place-items: center;
padding: 4px;
box-sizing: border-box;
pointer-events: none;
}
.ata-overlay {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 15% 12%, rgba(114, 121, 224, 0.22), transparent 50%),
radial-gradient(circle at 78% 8%, rgba(90, 210, 153, 0.12), transparent 48%),
linear-gradient(120deg, rgba(7, 11, 25, 0.54), rgba(7, 11, 25, 0.76));
opacity: 0;
transition: opacity 180ms ease;
}
.ata-drawer {
position: relative;
z-index: 1;
width: calc(100vw - 8px);
height: calc(100vh - 8px);
max-height: calc(100vh - 8px);
display: grid;
grid-template-rows: auto auto auto 1fr;
border-radius: 10px;
overflow: hidden;
opacity: 0;
visibility: hidden;
transform: translateY(22px) scale(0.985);
transition: transform 220ms ease, opacity 220ms ease, visibility 0ms linear 220ms;
background: linear-gradient(180deg, var(--ata-color-bg-panel), var(--ata-color-bg-panel-2) 42%, var(--ata-color-bg));
box-shadow: var(--ata-shadow-lg);
border: 1px solid var(--ata-color-border);
pointer-events: auto;
}
.ata-root[data-open="1"] {
pointer-events: auto;
}
.ata-root[data-open="1"] .ata-overlay {
opacity: 1;
}
.ata-root[data-open="1"] .ata-drawer {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
transition: transform 220ms ease, opacity 220ms ease, visibility 0ms linear 0ms;
}
.ata-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ata-space-3);
padding: 12px 18px 10px;
border-bottom: 1px solid var(--ata-color-border);
background: rgba(255, 255, 255, 0.03);
}
.ata-title-wrap {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
.ata-title-wrap h2 {
margin: 0;
font-family: var(--ata-font-head);
letter-spacing: 0.3px;
font-size: 27px;
line-height: 1;
text-transform: uppercase;
}
.ata-title-wrap p {
margin: 0;
color: var(--ata-color-muted);
font-size: 14px;
line-height: 1.2;
}
.ata-version {
color: #ffd34f;
}
.ata-close-btn {
border: 1px solid var(--ata-color-border);
background: rgba(255, 255, 255, 0.12);
color: var(--ata-color-text);
border-radius: var(--ata-radius-sm);
padding: var(--ata-space-2) var(--ata-space-3);
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.ata-close-btn:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.36);
}
.ata-tabs {
display: flex;
gap: var(--ata-space-2);
overflow-x: auto;
padding: var(--ata-space-3) var(--ata-space-5);
border-bottom: 1px solid var(--ata-color-border);
background: rgba(255, 255, 255, 0.02);
}
.ata-tab {
border: 1px solid var(--ata-color-border);
background: rgba(255, 255, 255, 0.06);
color: var(--ata-color-text);
border-radius: 999px;
padding: 9px 16px;
font-size: 18px;
cursor: pointer;
white-space: nowrap;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
}
.ata-tab:hover {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.34);
}
.ata-tab[data-active="1"] {
background: #fff;
border-color: #fff;
color: #1e2d56;
font-weight: 700;
}
.ata-content {
overflow: auto;
padding: var(--ata-space-5);
}
.ata-runtime-statusbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px 20px;
border-bottom: 1px solid var(--ata-color-border);
background: rgba(255, 255, 255, 0.05);
}
.ata-status-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.1);
color: var(--ata-color-text);
padding: 4px 10px;
font-size: 15px;
line-height: 1.2;
white-space: nowrap;
}
.ata-doc-linkable {
color: inherit;
text-decoration: none;
}
.ata-doc-linkable[data-doc-link="1"] {
cursor: help;
}
.ata-doc-linkable[data-doc-link="1"]:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.ata-doc-linkable[data-doc-link="1"]:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.72);
outline-offset: 2px;
}
.ata-notice.ata-doc-linkable,
.ata-match-note.ata-doc-linkable,
.ata-history-import-copy.ata-doc-linkable,
.ata-history-import-outcome.ata-doc-linkable {
display: block;
}
.ata-status-pill.ata-status-ok {
border-color: rgba(90, 210, 153, 0.5);
background: rgba(90, 210, 153, 0.2);
}
.ata-status-pill.ata-status-warn {
border-color: rgba(252, 129, 129, 0.55);
background: rgba(252, 129, 129, 0.2);
}
.ata-status-pill.ata-status-info {
border-color: rgba(153, 160, 245, 0.56);
background: rgba(114, 121, 224, 0.24);
}
.ata-status-pill.ata-status-neutral {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.08);
}
.ata-runtime-hint {
color: var(--ata-color-muted);
font-size: 15px;
}
.ata-content::-webkit-scrollbar {
width: 10px;
}
.ata-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.24);
border-radius: 999px;
}
.ata-notice {
margin-bottom: var(--ata-space-4);
padding: var(--ata-space-3) var(--ata-space-4);
border-radius: var(--ata-radius-md);
border: 1px solid transparent;
}
.ata-notice-info {
background: rgba(114, 121, 224, 0.18);
border-color: rgba(153, 160, 245, 0.36);
}
.ata-notice-error {
background: rgba(252, 129, 129, 0.18);
border-color: rgba(252, 129, 129, 0.36);
}
.ata-notice-success {
background: rgba(90, 210, 153, 0.18);
border-color: rgba(90, 210, 153, 0.38);
}
.ata-card,
.tournamentCard {
border: 1px solid var(--ata-color-border);
background: rgba(255, 255, 255, 0.08);
border-radius: var(--ata-radius-lg);
padding: var(--ata-space-4);
margin-bottom: var(--ata-space-4);
transition: background 150ms ease, border-color 150ms ease;
}
.ata-card:hover,
.tournamentCard:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.34);
}
.ata-card h3 {
margin: 0 0 var(--ata-space-3) 0;
font-size: 26px;
font-family: var(--ata-font-body);
font-weight: 700;
}
.ata-update-panel {
border-color: rgba(188, 205, 245, 0.34);
background:
radial-gradient(circle at 100% 0%, rgba(153, 184, 245, 0.14), transparent 38%),
rgba(255, 255, 255, 0.08);
}
.ata-update-panel-available {
border-color: rgba(255, 160, 122, 0.58);
background:
radial-gradient(circle at 100% 0%, rgba(255, 160, 122, 0.18), transparent 42%),
linear-gradient(145deg, rgba(255, 116, 86, 0.12), rgba(255, 196, 118, 0.08));
}
.ata-update-panel-current {
border-color: rgba(108, 224, 163, 0.42);
background:
radial-gradient(circle at 100% 0%, rgba(108, 224, 163, 0.14), transparent 40%),
rgba(255, 255, 255, 0.07);
}
.ata-update-panel-error {
border-color: rgba(252, 129, 129, 0.48);
background:
radial-gradient(circle at 100% 0%, rgba(252, 129, 129, 0.16), transparent 42%),
rgba(255, 255, 255, 0.07);
}
.ata-update-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.ata-update-summary {
display: grid;
gap: 4px;
}
.ata-update-title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ata-update-dot {
width: 11px;
height: 11px;
border-radius: 999px;
background: rgba(164, 190, 255, 0.96);
box-shadow: 0 0 0 3px rgba(164, 190, 255, 0.16);
}
.ata-update-panel-available .ata-update-dot {
background: #ff8b73;
box-shadow: 0 0 0 3px rgba(255, 139, 115, 0.18);
}
.ata-update-panel-current .ata-update-dot {
background: #6ce0a3;
box-shadow: 0 0 0 3px rgba(108, 224, 163, 0.18);
}
.ata-update-panel-error .ata-update-dot {
background: #ff8a8a;
box-shadow: 0 0 0 3px rgba(255, 138, 138, 0.18);
}
.ata-update-title {
font-size: 18px;
font-weight: 800;
}
.ata-update-copy {
margin: 0;
}
.ata-update-actions {
align-items: center;
}
.ata-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin-bottom: var(--ata-space-3);
}
.ata-heading-row h3 {
margin: 0;
}
.ata-help-links {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.ata-help-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.38);
background: rgba(255, 255, 255, 0.11);
color: #f4f7ff;
text-decoration: none;
font-size: 12px;
font-weight: 700;
line-height: 1;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.ata-help-link-tech {
border-color: rgba(153, 184, 245, 0.62);
background: rgba(153, 184, 245, 0.2);
color: #e8efff;
}
.ata-help-link-rule {
border-color: rgba(90, 210, 153, 0.62);
background: rgba(90, 210, 153, 0.18);
color: #baf5da;
}
.ata-help-link:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.62);
transform: translateY(-1px);
}
.ata-help-link:focus-visible {
outline: none;
border-color: var(--ata-color-focus);
box-shadow: 0 0 0 2px rgba(255, 211, 79, 0.24);
}
.ata-grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--ata-space-3);
}
.ata-grid-3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--ata-space-3);
}
.ata-grid-3-tight {
gap: 10px;
}
.ata-field-span-3 {
grid-column: 1 / -1;
}
.ata-create-form {
display: grid;
gap: 10px;
}
.ata-create-layout {
display: grid;
grid-template-columns: minmax(0, 2.35fr) minmax(300px, 1fr);
gap: 12px;
align-items: start;
}
.ata-create-main,
.ata-create-side {
display: grid;
gap: 10px;
}
.ata-create-side {
border: 1px solid var(--ata-color-border);
border-radius: var(--ata-radius-md);
padding: 10px;
background: rgba(255, 255, 255, 0.05);
}
.ata-estimate-card {
display: grid;
gap: 8px;
border: 1px solid rgba(255, 211, 79, 0.34);
border-radius: var(--ata-radius-md);
padding: 10px;
background:
radial-gradient(circle at 100% 0%, rgba(255, 211, 79, 0.12), transparent 40%),
rgba(17, 28, 56, 0.86);
}
.ata-estimate-card-pending {
border-color: rgba(188, 205, 245, 0.28);
background:
radial-gradient(circle at 100% 0%, rgba(188, 205, 245, 0.12), transparent 42%),
rgba(17, 28, 56, 0.82);
}
.ata-estimate-card-progress {
border-color: rgba(90, 210, 153, 0.36);
background:
radial-gradient(circle at 100% 0%, rgba(90, 210, 153, 0.12), transparent 40%),
rgba(17, 28, 56, 0.84);
}
.ata-estimate-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ata-estimate-head strong {
font-size: 14px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ata-estimate-value {
font-size: 30px;
line-height: 1;
font-weight: 900;
color: #fff1c5;
}
.ata-estimate-value-pending {
font-size: 20px;
color: #edf3ff;
}
.ata-estimate-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ata-estimate-meta span,
.ata-estimate-range {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.08);
padding: 3px 9px;
font-size: 12px;
line-height: 1.2;
}
.ata-estimate-range {
width: fit-content;
border-color: rgba(255, 211, 79, 0.44);
color: #fff1c5;
}
.ata-field {
display: grid;
gap: var(--ata-space-1);
}
.ata-field label {
color: var(--ata-color-muted);
font-size: 15px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.ata-field input,
.ata-field select,
.ata-field textarea {
width: 100%;
border-radius: var(--ata-radius-sm);
border: 1px solid var(--ata-control-border);
background: var(--ata-control-bg);
color: var(--ata-color-text);
padding: 11px 12px;
font-size: 18px;
font-weight: 600;
box-sizing: border-box;
transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease, color 120ms ease;
}
.ata-field input:focus,
.ata-field select:focus,
.ata-field textarea:focus {
outline: none;
border-color: var(--ata-color-focus);
box-shadow: 0 0 0 2px rgba(255, 211, 79, 0.26);
background: var(--ata-control-bg-hover);
}
.ata-field textarea {
min-height: 92px;
resize: vertical;
}
.ata-create-form .ata-field label {
font-size: 13px;
letter-spacing: 0.35px;
}
.ata-create-form .ata-field input,
.ata-create-form .ata-field select,
.ata-create-form .ata-field textarea {
padding: 9px 10px;
font-size: 16px;
line-height: 1.25;
}
.ata-create-form #ata-participants {
min-height: clamp(190px, 30vh, 320px);
}
.ata-field-readonly {
display: flex;
align-items: center;
width: 100%;
border-radius: var(--ata-radius-sm);
border: 1px solid rgba(185, 199, 236, 0.22);
background: var(--ata-control-bg-disabled);
color: rgba(232, 237, 255, 0.78);
padding: 9px 10px;
font-size: 16px;
line-height: 1.25;
font-weight: 600;
box-sizing: border-box;
}
.ata-form-inline-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ata-space-2);
}
.ata-form-inline-actions #ata-preset-select {
flex: 1 1 320px;
min-width: 240px;
}
.ata-preset-pill {
display: inline-flex;
align-items: center;
border: 1px solid rgba(255, 211, 79, 0.48);
border-radius: 999px;
padding: 5px 11px;
background: rgba(255, 211, 79, 0.14);
color: #ffe39a;
font-size: 14px;
line-height: 1.1;
white-space: nowrap;
}
.ata-score-grid select,
.ata-score-grid input[type="number"] {
border-radius: 8px;
border: 1px solid var(--ata-control-border);
background: var(--ata-control-bg);
color: var(--ata-color-text);
box-sizing: border-box;
min-height: 40px;
font-size: 17px;
font-weight: 600;
padding: 7px 10px;
transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease, color 120ms ease;
}
.ata-score-grid select {
width: 100%;
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, rgba(228, 238, 255, 0.92) 50%),
linear-gradient(135deg, rgba(228, 238, 255, 0.92) 50%, transparent 50%),
linear-gradient(to right, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.12));
background-position:
calc(100% - 16px) 50%,
calc(100% - 10px) 50%,
calc(100% - 30px) 0;
background-size: 6px 6px, 6px 6px, 1px 100%;
background-repeat: no-repeat;
padding-right: 36px;
}
.ata-score-grid input[type="number"] {
width: 100%;
text-align: center;
}
.ata-score-grid select:hover,
.ata-score-grid input[type="number"]:hover,
.ata-field input:hover,
.ata-field select:hover,
.ata-field textarea:hover {
background: var(--ata-control-bg-hover);
border-color: var(--ata-control-border-strong);
}
.ata-score-grid select:focus,
.ata-score-grid input[type="number"]:focus {
outline: none;
border-color: var(--ata-color-focus);
box-shadow: 0 0 0 2px rgba(255, 211, 79, 0.22);
background: var(--ata-control-bg-hover);
}
.ata-score-grid select:disabled,
.ata-score-grid input[type="number"]:disabled,
.ata-field input:disabled,
.ata-field select:disabled,
.ata-field textarea:disabled {
background: var(--ata-control-bg-disabled);
color: rgba(229, 236, 255, 0.56);
border-color: rgba(185, 199, 236, 0.2);
cursor: not-allowed;
}
.ata-drawer input::placeholder,
.ata-drawer textarea::placeholder {
color: rgba(220, 230, 255, 0.62);
}
.ata-drawer select option {
background: #2d3e76;
color: #eff3ff;
}
.ata-actions {
display: flex;
flex-wrap: wrap;
gap: var(--ata-space-2);
}
.ata-btn {
border: 1px solid var(--ata-color-border);
background: rgba(255, 255, 255, 0.12);
color: var(--ata-color-text);
border-radius: var(--ata-radius-sm);
padding: 11px 15px;
font-size: 17px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.ata-btn-sm {
padding: 7px 10px;
font-size: 14px;
line-height: 1.2;
}
.ata-btn:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.36);
}
.ata-btn:active {
transform: translateY(1px);
}
.ata-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.ata-btn-primary {
background: var(--ata-color-accent);
color: #071611;
border-color: var(--ata-color-accent);
font-weight: 700;
}
.ata-btn-primary:hover {
background: #77dfae;
border-color: #77dfae;
}
.ata-btn-danger {
border-color: rgba(252, 129, 129, 0.55);
background: rgba(252, 129, 129, 0.2);
}
.ata-pill {
display: inline-flex;
align-items: center;
border: 1px solid var(--ata-color-border);
border-radius: 999px;
padding: 2px 9px;
margin-right: 6px;
margin-bottom: 6px;
font-size: 13px;
}
.ata-tournament-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ata-tournament-mode-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 2px 10px;
border: 1px solid rgba(153, 160, 245, 0.56);
background: rgba(114, 121, 224, 0.22);
color: #dce2ff;
font-size: 13px;
font-weight: 700;
}
.ata-tournament-meta {
display: grid;
gap: 8px;
margin-top: 8px;
}
.ata-meta-block {
display: grid;
gap: 5px;
}
.ata-meta-heading {
color: var(--ata-color-muted);
font-size: 12px;
letter-spacing: 0.35px;
text-transform: uppercase;
}
.ata-info-tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin: 0;
}
.ata-info-tag {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.24);
background: rgba(255, 255, 255, 0.08);
color: rgba(236, 242, 255, 0.92);
font-size: 12px;
line-height: 1.2;
padding: 2px 8px;
white-space: nowrap;
}
.ata-info-tag.ata-info-tag-key {
background: rgba(114, 121, 224, 0.22);
border-color: rgba(153, 160, 245, 0.52);
color: #dce2ff;
font-weight: 700;
}
.ata-info-tag.ata-info-tag-accent {
background: rgba(90, 210, 153, 0.16);
border-color: rgba(90, 210, 153, 0.44);
color: #7be7b5;
}
.ata-player-chip-cloud {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.ata-player-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(188, 205, 245, 0.4);
background: rgba(34, 53, 98, 0.62);
color: #eaf0ff;
font-size: 13px;
line-height: 1.2;
padding: 2px 9px;
}
.ata-player-chip-count {
color: #a9bce8;
font-size: 12px;
}
.ata-table-wrap {
overflow-x: auto;
border: 1px solid var(--ata-color-border);
border-radius: var(--ata-radius-md);
background: rgba(255, 255, 255, 0.04);
}
table.ata-table,
table.tournamentRanking {
width: 100%;
border-collapse: collapse;
font-size: 17px;
}
.ata-table th,
.ata-table td {
text-align: left;
border-bottom: 1px solid rgba(157, 180, 197, 0.16);
padding: 10px 12px;
vertical-align: middle;
}
.ata-table th {
color: var(--ata-color-muted);
font-weight: 600;
text-transform: uppercase;
font-size: 15px;
letter-spacing: 0.4px;
}
.ata-table tbody tr:nth-of-type(odd) {
background: #ffffff0d;
}
.ata-table tbody tr:hover {
background: #ffd9262b;
}
.ata-table tr.ata-row-inactive {
background: rgba(17, 27, 49, 0.62) !important;
}
.ata-table tr.ata-row-inactive td {
color: rgba(211, 223, 250, 0.78);
}
.ata-table tr.ata-row-completed {
background: rgba(90, 210, 153, 0.12) !important;
}
.ata-table tr.ata-row-completed td {
border-bottom-color: rgba(90, 210, 153, 0.28);
}
.ata-pill-open-slot {
display: inline-block;
color: #ffd34f;
font-weight: 700;
}
.ata-match-list {
display: grid;
gap: 8px;
}
.ata-matches-card {
max-width: min(1240px, calc(100vw - 28px));
margin: 0 auto;
}
.ata-matches-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.ata-segmented {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.06);
}
.ata-segmented-btn {
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(232, 237, 255, 0.86);
font-size: 11px;
line-height: 1.2;
padding: 4px 8px;
cursor: pointer;
}
.ata-segmented-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.ata-segmented-btn[data-active="1"] {
background: rgba(255, 255, 255, 0.95);
color: #1e2d56;
font-weight: 700;
}
.ata-match-card {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--ata-radius-md);
padding: 8px 10px;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
column-gap: 10px;
row-gap: 6px;
transition: background 120ms ease, border-color 120ms ease;
}
.ata-match-card:hover {
background: rgba(255, 255, 255, 0.09);
border-color: rgba(255, 255, 255, 0.32);
}
.ata-match-card.ata-row-inactive {
background: rgba(17, 27, 49, 0.62);
}
.ata-match-card.ata-row-completed {
background: rgba(90, 210, 153, 0.12);
border-color: rgba(90, 210, 153, 0.35);
}
.ata-match-card.ata-row-live {
background: rgba(90, 210, 153, 0.15);
border-color: rgba(110, 231, 183, 0.62);
}
.ata-match-card.ata-row-ready {
background: rgba(106, 146, 237, 0.14);
border-color: rgba(153, 184, 245, 0.52);
}
.ata-match-card.ata-row-next {
border-color: rgba(255, 255, 255, 0.46);
box-shadow: inset 4px 0 0 rgba(255, 255, 255, 0.75);
}
.ata-match-card.ata-row-blocked {
background: rgba(20, 31, 57, 0.7);
border-color: rgba(126, 145, 196, 0.34);
}
.ata-match-card.ata-row-bye {
background: rgba(255, 211, 79, 0.09);
border-color: rgba(255, 211, 79, 0.42);
}
.ata-match-card.ata-row-final {
position: relative;
isolation: isolate;
overflow: hidden;
border-color: rgba(255, 224, 140, 0.72);
background:
radial-gradient(circle at 88% -66%, rgba(255, 231, 158, 0.26), transparent 52%),
linear-gradient(180deg, rgba(255, 211, 79, 0.12), rgba(255, 255, 255, 0.06));
box-shadow: 0 0 0 1px rgba(255, 224, 140, 0.38), 0 8px 18px rgba(46, 30, 8, 0.24);
}
.ata-match-card.ata-row-final::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(circle at 50% 0%, rgba(255, 228, 146, 0.2), transparent 62%);
opacity: 0.82;
}
.ata-match-card.ata-row-final > * {
position: relative;
z-index: 1;
}
.ata-match-card.ata-row-final:hover {
border-color: rgba(255, 229, 150, 0.9);
}
.ata-match-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
flex: 1 1 420px;
min-width: 280px;
}
.ata-match-title-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
flex-wrap: wrap;
flex: 1 1 auto;
min-width: 0;
}
.ata-match-meta-inline {
display: inline-flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.ata-match-advance-pill,
.ata-match-context-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.24);
padding: 1px 7px;
font-size: 11px;
line-height: 1.2;
}
.ata-match-advance-pill {
border-color: rgba(90, 210, 153, 0.58);
background: rgba(90, 210, 153, 0.22);
color: #7be7b5;
font-weight: 800;
font-size: 12px;
padding: 2px 10px;
box-shadow: 0 0 0 1px rgba(90, 210, 153, 0.22) inset;
}
.ata-match-advance-pill.ata-match-advance-bye {
border-color: rgba(255, 211, 79, 0.62);
background: rgba(255, 211, 79, 0.2);
color: #ffe07a;
}
.ata-match-context-pill {
color: rgba(226, 234, 255, 0.88);
border-color: rgba(188, 205, 245, 0.38);
background: rgba(255, 255, 255, 0.08);
}
.ata-match-context-pill.ata-match-context-open {
border-color: rgba(188, 205, 245, 0.38);
}
.ata-match-context-pill.ata-match-context-completed {
border-color: rgba(90, 210, 153, 0.5);
background: rgba(90, 210, 153, 0.16);
}
.ata-match-context-pill.ata-match-context-bye {
border-color: rgba(255, 211, 79, 0.58);
background: rgba(255, 211, 79, 0.16);
}
.ata-match-next-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.56);
background: rgba(255, 255, 255, 0.14);
color: #f4f7ff;
padding: 1px 8px;
font-size: 11px;
line-height: 1.2;
font-weight: 700;
letter-spacing: 0.01em;
}
.ata-match-final-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 224, 140, 0.85);
background: rgba(98, 74, 18, 0.88);
color: #fff1c5;
padding: 1px 8px;
font-size: 11px;
line-height: 1.2;
font-weight: 800;
letter-spacing: 0.01em;
text-shadow: 0 1px 0 rgba(63, 40, 8, 0.52);
}
.ata-next-hint {
margin: 8px 0 2px 0;
color: rgba(235, 239, 255, 0.82);
}
.ata-match-pairing {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
font-size: 14px;
line-height: 1.2;
}
.ata-match-pairing .ata-vs {
color: var(--ata-color-muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.35px;
}
.ata-pairing-player {
font-weight: 700;
color: #edf3ff;
}
.ata-pairing-player.is-winner {
color: #72e5b0;
}
.ata-pairing-player.is-champion {
color: #ffefb9;
border: 1px solid rgba(255, 224, 140, 0.76);
background: rgba(98, 74, 18, 0.62);
border-radius: 999px;
padding: 1px 8px;
text-shadow: 0 1px 0 rgba(57, 35, 8, 0.58);
box-shadow: 0 0 0 1px rgba(255, 224, 140, 0.18);
}
.ata-pairing-player.is-loser {
color: rgba(229, 237, 255, 0.72);
}
.ata-pairing-player.ata-open-slot {
color: #ffd34f;
}
.ata-match-status {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 1px 7px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.08);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.ata-match-status-open {
border-color: rgba(188, 205, 245, 0.38);
color: #e8efff;
}
.ata-match-status-completed {
border-color: rgba(90, 210, 153, 0.5);
background: rgba(90, 210, 153, 0.18);
color: #72e5b0;
}
.ata-match-status-bye {
border-color: rgba(255, 211, 79, 0.55);
background: rgba(255, 211, 79, 0.16);
color: #ffd34f;
}
.ata-match-advance-pill.ata-match-advance-final {
border-color: rgba(255, 224, 140, 0.82);
background: rgba(98, 74, 18, 0.86);
color: #fff1c5;
box-shadow: 0 0 0 1px rgba(255, 224, 140, 0.28) inset;
}
.ata-score-grid {
display: grid;
grid-template-columns: repeat(2, 58px);
gap: 6px;
min-width: 0;
align-items: center;
width: auto;
flex: 0 0 auto;
}
.ata-match-editor {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex: 1 1 100%;
min-width: 0;
}
.ata-editor-actions {
display: grid;
grid-template-columns: minmax(100px, 126px) minmax(136px, 176px);
gap: 6px;
align-items: center;
margin-left: auto;
}
.ata-editor-actions .ata-btn {
min-height: 34px;
padding: 6px 10px;
font-size: 14px;
}
.ata-score-grid input[type="number"] {
width: 100%;
max-width: 58px;
min-height: 30px;
padding: 4px 7px;
font-size: 14px;
}
.ata-match-note {
font-size: 12px;
color: var(--ata-color-muted);
line-height: 1.25;
width: 100%;
}
.ata-small {
font-size: 13px;
color: var(--ata-color-muted);
line-height: 1.3;
}
.ata-debug-actions {
margin-bottom: var(--ata-space-2);
}
.ata-debug-log {
margin: 0;
padding: 12px;
max-height: 320px;
overflow: auto;
border: 1px solid rgba(188, 205, 245, 0.26);
border-radius: var(--ata-radius-sm);
background: rgba(17, 27, 49, 0.72);
color: #eaf1ff;
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.ata-group-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--ata-space-3);
}
.ata-bracket-dock {
position: relative;
}
.ata-bracket-shell {
border: 1px solid var(--ata-color-border);
border-radius: var(--ata-radius-md);
overflow: hidden;
background: rgba(255, 255, 255, 0.04);
min-height: 620px;
}
.ata-bracket-frame {
width: 100%;
min-height: 620px;
height: 620px;
border: 0;
background: transparent;
}
.ata-bracket-fallback {
display: none;
padding: var(--ata-space-3);
background: rgba(255, 255, 255, 0.05);
border-top: 1px solid var(--ata-color-border);
}
.ata-bracket-fallback[data-visible="1"] {
display: block;
}
.ata-bracket-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--ata-space-2);
}
.ata-bracket-round {
border: 1px solid var(--ata-color-border);
border-radius: var(--ata-radius-sm);
padding: var(--ata-space-2);
}
.ata-bracket-round.ata-bracket-round-final {
border-color: rgba(255, 224, 140, 0.68);
background: linear-gradient(180deg, rgba(255, 211, 79, 0.2), rgba(255, 211, 79, 0.06) 48%, rgba(255, 255, 255, 0.02));
box-shadow: inset 0 0 0 1px rgba(255, 224, 140, 0.2), 0 8px 18px rgba(48, 32, 8, 0.18);
}
.ata-bracket-round.ata-bracket-round-final > strong {
display: inline-flex;
align-items: center;
gap: 6px;
color: #fff4cb;
text-shadow: 0 1px 0 rgba(58, 36, 8, 0.45);
}
.ata-bracket-round.ata-bracket-round-final > strong::before {
content: "🏆";
}
.ata-bracket-match {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 6px;
margin-top: 8px;
background: #ffffff0d;
display: grid;
gap: 4px;
}
.ata-bracket-player {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #edf3ff;
font-weight: 700;
}
.ata-bracket-player.is-winner {
color: #72e5b0;
}
.ata-bracket-player.is-loser {
color: rgba(229, 237, 255, 0.72);
}
.ata-bracket-player.ata-open-slot {
color: #ffd34f;
}
.ata-bracket-score {
min-width: 18px;
text-align: right;
font-weight: 800;
}
.ata-bracket-score.is-win {
color: #72e5b0;
}
.ata-bracket-score.is-loss {
color: #ff9a9a;
}
.ata-bracket-round.ata-bracket-round-final .ata-bracket-match {
border-color: rgba(255, 224, 140, 0.62);
background:
radial-gradient(circle at 84% -32%, rgba(255, 228, 146, 0.22), transparent 56%),
linear-gradient(180deg, rgba(255, 211, 79, 0.12), rgba(255, 255, 255, 0.05));
box-shadow: 0 0 0 1px rgba(255, 224, 140, 0.18);
}
.ata-bracket-round.ata-bracket-round-third-place {
border-color: rgba(255, 184, 120, 0.58);
background: linear-gradient(180deg, rgba(255, 184, 120, 0.14), rgba(255, 255, 255, 0.04));
}
.ata-bracket-round.ata-bracket-round-third-place > strong {
color: #ffd7b0;
}
.ata-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ata-space-3);
border: 1px solid var(--ata-color-border);
border-radius: var(--ata-radius-sm);
padding: 10px 12px;
margin-bottom: var(--ata-space-2);
background: rgba(255, 255, 255, 0.06);
}
.ata-toggle-compact {
padding: 8px 10px;
margin-bottom: 0;
}
.ata-create-help {
margin: 0;
font-size: 14px;
}
.ata-toggle input {
width: 18px;
height: 18px;
accent-color: var(--ata-color-accent);
}
@media (max-width: 1250px) {
.ata-grid-3 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ata-create-layout {
grid-template-columns: 1fr;
}
.ata-match-card {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
align-items: stretch;
}
.ata-match-card-head {
min-width: 0;
}
.ata-score-grid {
grid-template-columns: repeat(2, 58px);
width: auto;
}
.ata-match-editor {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
align-items: stretch;
}
.ata-editor-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
margin-left: 0;
}
}
@media (max-width: 820px) {
.ata-root {
padding: 2px;
}
.ata-drawer {
width: calc(100vw - 4px);
height: calc(100vh - 4px);
max-height: calc(100vh - 4px);
border-radius: 8px;
}
.ata-grid-2 {
grid-template-columns: 1fr;
}
.ata-grid-3 {
grid-template-columns: 1fr;
}
.ata-runtime-statusbar {
padding: 10px 14px;
}
.ata-header {
align-items: flex-start;
padding: 9px 12px;
gap: 8px;
}
.ata-title-wrap {
gap: 6px;
}
.ata-title-wrap h2 {
font-size: 21px;
letter-spacing: 0.2px;
}
.ata-title-wrap p {
margin: 0;
font-size: 12px;
line-height: 1.2;
}
.ata-close-btn {
padding: 6px 10px;
font-size: 14px;
}
.ata-bracket-shell {
min-height: 420px;
}
.ata-bracket-frame {
min-height: 420px;
height: 420px;
}
.ata-match-pairing {
font-size: 15px;
}
.ata-score-grid {
grid-template-columns: repeat(2, 58px);
}
.ata-editor-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
}
}
`;
const ATA_PDC_LOGO_DATA_URI = "data:image/png;base64,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";
const STATUS_COMPLETED = "completed";
const STATUS_PENDING = "pending";
const MATCH_STAGE_KO = "ko";
const MATCH_STAGE_GROUP = "group";
const MATCH_STAGE_LEAGUE = "league";
const KO_ENGINE_VERSION = 3;
const KO_DRAW_MODE_SEEDED = "seeded";
const KO_DRAW_MODE_OPEN_DRAW = "open_draw";
const X01_VARIANT = "X01";
const X01_PRESET_LEGACY_PDC_STANDARD = "pdc_standard";
const X01_PRESET_PDC_EUROPEAN_TOUR_OFFICIAL = "pdc_european_tour_official";
const X01_PRESET_PDC_501_DOUBLE_OUT_BASIC = "pdc_501_double_out_basic";
const X01_PRESET_CUSTOM = "custom";
const X01_IN_MODES = Object.freeze(["Straight", "Double", "Master"]);
const X01_OUT_MODES = Object.freeze(["Straight", "Double", "Master"]);
const X01_BULL_MODES = Object.freeze(["25/50", "50/50"]);
const X01_BULL_OFF_MODES = Object.freeze(["Off", "Normal", "Official"]);
const X01_MAX_ROUNDS_OPTIONS = Object.freeze([15, 20, 50, 80]);
const X01_START_SCORE_OPTIONS = Object.freeze([121, 170, 301, 501, 701, 901]);
const TOURNAMENT_TIME_PROFILE_FAST = "fast";
const TOURNAMENT_TIME_PROFILE_NORMAL = "normal";
const TOURNAMENT_TIME_PROFILE_SLOW = "slow";
const TOURNAMENT_DURATION_DEFAULT_BOARD_COUNT = 1;
const TOURNAMENT_DURATION_MAX_BOARD_COUNT = 32;
const TOURNAMENT_TIME_PROFILES = Object.freeze([
TOURNAMENT_TIME_PROFILE_FAST,
TOURNAMENT_TIME_PROFILE_NORMAL,
TOURNAMENT_TIME_PROFILE_SLOW,
]);
const MATCH_SORT_MODE_READY_FIRST = "ready_first";
const MATCH_SORT_MODE_ROUND = "round";
const MATCH_SORT_MODE_STATUS = "status";
const TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE = "promoter_h2h_minitable";
const TIE_BREAK_PROFILE_PROMOTER_POINTS_LEGDIFF = "promoter_points_legdiff";
const TIE_BREAK_PROFILES = Object.freeze([
TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE,
TIE_BREAK_PROFILE_PROMOTER_POINTS_LEGDIFF,
]);
const LEGACY_TIE_BREAK_MODE_DRA_STRICT = "dra_strict";
const LEGACY_TIE_BREAK_MODE_LEGACY = "legacy";
const MATCH_SORT_MODES = Object.freeze([
MATCH_SORT_MODE_READY_FIRST,
MATCH_SORT_MODE_ROUND,
MATCH_SORT_MODE_STATUS,
]);
const TAB_IDS = Object.freeze(["tournament", "matches", "view", "io", "settings"]);
const TAB_META = Object.freeze([
{ id: "tournament", label: "Turnier" },
{ id: "matches", label: "Spiele" },
{ id: "view", label: "Turnierbaum" },
{ id: "io", label: "Import/Export" },
{ id: "settings", label: "Einstellungen" },
]);
const TECHNICAL_PARTICIPANT_HARD_MAX = 128;
const MODE_PARTICIPANT_LIMITS = Object.freeze({
ko: Object.freeze({ label: "KO", min: 2, max: 128 }),
league: Object.freeze({ label: "Liga", min: 2, max: 16 }),
groups_ko: Object.freeze({ label: "Gruppenphase + KO", min: 4, max: 16 }),
});
const BYE_PLACEHOLDER_TOKENS = new Set([
"bye",
"freilos",
"tbd",
"tobeconfirmed",
"tobedetermined",
"unknown",
"none",
"null",
"na",
]);
if (window[RUNTIME_GUARD_KEY]) {
return;
}
window[RUNTIME_GUARD_KEY] = true;
const state = {
ready: false,
drawerOpen: false,
activeTab: "tournament",
lastFocused: null,
notice: { type: "info", message: "" },
noticeTimer: null,
saveTimer: null,
host: null,
shadowRoot: null,
patchedHistory: null,
routeKey: routeKey(),
store: createDefaultStore(),
bracket: {
iframe: null,
ready: false,
failed: false,
timeoutHandle: null,
frameHeight: 0,
lastError: "",
},
autoDetect: {
observer: null,
queued: false,
lastScanAt: 0,
lastFingerprint: "",
},
apiAutomation: {
syncing: false,
startingMatchId: "",
authBackoffUntil: 0,
lastAuthNoticeAt: 0,
authToken: "",
authTokenExpiresAt: 0,
authTokenSource: "",
authRefreshPromise: null,
authHeaderCaptureInstalled: false,
authHeaderBridgeInjected: false,
},
matchReturnShortcut: {
root: null,
syncing: false,
inlineSyncingByLobby: {},
inlineOutcomeByLobby: {},
pendingConfirmationByLobby: {},
pendingDrawUnlockOverride: null,
},
updateStatus: {
capable: false,
status: "idle",
installedVersion: "",
remoteVersion: "",
available: false,
checkedAt: 0,
sourceUrl: "",
downloadUrl: USERSCRIPT_DOWNLOAD_URL,
error: "",
stale: false,
validators: {},
},
updateStatusSignature: "",
updateCheckPromise: null,
runtimeStatusSignature: "",
cleanupStack: [],
};
function nowIso() {
return new Date().toISOString();
}
function routeKey() {
return `${location.pathname}${location.search}${location.hash}`;
}
function normalizeText(value) {
return String(value || "")
.trim()
.replace(/\s+/g, " ");
}
function normalizeLookup(value) {
return normalizeText(value)
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "");
}
function normalizeToken(value) {
return normalizeLookup(value).replace(/[^a-z0-9]+/g, "");
}
function cloneSerializable(value) {
try {
return JSON.parse(JSON.stringify(value));
} catch (_) {
return null;
}
}
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function toPromise(value) {
return value && typeof value.then === "function" ? value : Promise.resolve(value);
}
function clampInt(value, fallback, min, max) {
const num = Number.parseInt(String(value || ""), 10);
if (!Number.isFinite(num)) {
return fallback;
}
return Math.max(min, Math.min(max, num));
}
function uuid(prefix) {
const random = Math.random().toString(36).slice(2, 8);
const timestamp = Date.now().toString(36);
return `${prefix}-${timestamp}-${random}`;
}
function nextPowerOfTwo(value) {
let size = 1;
while (size < value) {
size *= 2;
}
return size;
}
function getKoRoundSize(round, totalRounds) {
const normalizedRound = clampInt(round, null, 1, 64);
const normalizedTotalRounds = clampInt(totalRounds, null, 1, 64);
if (!Number.isFinite(normalizedRound) || !Number.isFinite(normalizedTotalRounds) || normalizedRound > normalizedTotalRounds) {
return null;
}
return 2 ** (normalizedTotalRounds - normalizedRound + 1);
}
function getKoRoundLabel(round, totalRounds, fallbackPrefix = "Runde") {
const normalizedRound = clampInt(round, null, 1, 64);
const normalizedTotalRounds = clampInt(totalRounds, null, 1, 64);
const prefix = normalizeText(fallbackPrefix || "Runde") || "Runde";
if (!Number.isFinite(normalizedRound) || !Number.isFinite(normalizedTotalRounds) || normalizedRound > normalizedTotalRounds) {
return Number.isFinite(normalizedRound) ? `${prefix} ${normalizedRound}` : prefix;
}
const roundSize = getKoRoundSize(normalizedRound, normalizedTotalRounds);
if (!Number.isFinite(roundSize)) {
return `${prefix} ${normalizedRound}`;
}
if (roundSize === 2) {
return "Finale";
}
if (roundSize === 4) {
return "Halbfinale";
}
if (roundSize === 8) {
return "Viertelfinale";
}
if (roundSize === 16) {
return "Achtelfinale";
}
if (roundSize >= 32) {
return `Letzte ${roundSize}`;
}
return `${prefix} ${normalizedRound}`;
}
function getKoRoundMatchLabel(round, totalRounds, matchNumber) {
const roundLabel = getKoRoundLabel(round, totalRounds);
const normalizedMatchNumber = clampInt(matchNumber, null, 1, 256);
if (!Number.isFinite(normalizedMatchNumber)) {
return roundLabel;
}
return `${roundLabel} / Spiel ${normalizedMatchNumber}`;
}
function parseParticipantLines(rawLines) {
const lines = String(rawLines || "").split(/\r?\n/);
const seen = new Set();
const participants = [];
lines.forEach((line) => {
const name = normalizeText(line);
if (!name) {
return;
}
const key = normalizeLookup(name);
if (seen.has(key)) {
return;
}
seen.add(key);
participants.push({ id: uuid("p"), name });
});
return participants;
}
function randomInt(maxExclusive) {
const max = Number(maxExclusive);
if (!Number.isFinite(max) || max <= 0) {
return 0;
}
const cryptoApi = window.crypto || window.msCrypto;
if (cryptoApi && typeof cryptoApi.getRandomValues === "function") {
const buffer = new Uint32Array(1);
const maxUnbiased = Math.floor(0x100000000 / max) * max;
let value = 0;
do {
cryptoApi.getRandomValues(buffer);
value = buffer[0];
} while (value >= maxUnbiased);
return value % max;
}
return Math.floor(Math.random() * max);
}
function shuffleArray(values) {
const shuffled = Array.isArray(values) ? values.slice() : [];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const swapIndex = randomInt(index + 1);
const current = shuffled[index];
shuffled[index] = shuffled[swapIndex];
shuffled[swapIndex] = current;
}
return shuffled;
}
const MATCH_START_DEBUG_SESSION_LIMIT = 12;
const MATCH_START_DEBUG_STEP_LIMIT = 48;
const MATCH_START_DEBUG_REPORT_SESSION_LIMIT = 5;
function createDefaultDebugData() {
return {
matchStartSessions: [],
};
}
function cloneDebugValue(value, fallbackValue = null) {
const cloned = cloneSerializable(value);
if (cloned === null && value !== null && value !== undefined) {
return fallbackValue;
}
return cloned;
}
function normalizeMatchStartCleanup(rawCleanup) {
const cleanup = rawCleanup && typeof rawCleanup === "object" ? rawCleanup : {};
return {
attempted: Boolean(cleanup.attempted),
ok: Boolean(cleanup.ok),
skippedReason: normalizeText(cleanup.skippedReason || ""),
message: normalizeText(cleanup.message || ""),
error: cloneDebugValue(cleanup.error, null),
};
}
function normalizeMatchStartDebugStep(rawStep) {
const step = rawStep && typeof rawStep === "object" ? rawStep : {};
return {
at: normalizeText(step.at || nowIso()) || nowIso(),
step: normalizeText(step.step || "unknown") || "unknown",
status: normalizeText(step.status || "info") || "info",
details: cloneDebugValue(step.details, {}),
};
}
function normalizeMatchStartDebugSession(rawSession) {
const session = rawSession && typeof rawSession === "object" ? rawSession : {};
const steps = Array.isArray(session.steps)
? session.steps.map((entry) => normalizeMatchStartDebugStep(entry)).slice(-MATCH_START_DEBUG_STEP_LIMIT)
: [];
return {
id: normalizeText(session.id || uuid("matchstart-debug")) || uuid("matchstart-debug"),
kind: "match_start",
startedAt: normalizeText(session.startedAt || nowIso()) || nowIso(),
finishedAt: normalizeText(session.finishedAt || "") || null,
outcome: normalizeText(session.outcome || "running") || "running",
matchId: normalizeText(session.matchId || ""),
tournamentId: normalizeText(session.tournamentId || ""),
lobbyId: normalizeText(session.lobbyId || "") || null,
context: cloneDebugValue(session.context, {}) || {},
summary: cloneDebugValue(session.summary, {}) || {},
payload: cloneDebugValue(session.payload, null),
cleanup: normalizeMatchStartCleanup(session.cleanup),
steps,
};
}
function normalizeDebugData(rawDebugData) {
const debugData = rawDebugData && typeof rawDebugData === "object" ? rawDebugData : {};
const matchStartSessions = Array.isArray(debugData.matchStartSessions)
? debugData.matchStartSessions.map((entry) => normalizeMatchStartDebugSession(entry)).slice(-MATCH_START_DEBUG_SESSION_LIMIT)
: [];
return {
matchStartSessions,
};
}
function createMatchStartDebugSession(context = {}) {
return normalizeMatchStartDebugSession({
id: uuid("matchstart-debug"),
startedAt: nowIso(),
finishedAt: null,
outcome: "running",
matchId: normalizeText(context.matchId || ""),
tournamentId: normalizeText(context.tournamentId || ""),
lobbyId: normalizeText(context.lobbyId || "") || null,
context: cloneDebugValue(context, {}) || {},
summary: {},
payload: null,
cleanup: normalizeMatchStartCleanup(null),
steps: [],
});
}
function appendMatchStartDebugStep(session, step, status, details = null) {
if (!session || typeof session !== "object") {
return session;
}
if (!Array.isArray(session.steps)) {
session.steps = [];
}
session.steps.push(normalizeMatchStartDebugStep({
at: nowIso(),
step,
status,
details,
}));
if (session.steps.length > MATCH_START_DEBUG_STEP_LIMIT) {
session.steps = session.steps.slice(-MATCH_START_DEBUG_STEP_LIMIT);
}
return session;
}
function finalizeMatchStartDebugSession(session, outcome, details = {}) {
if (!session || typeof session !== "object") {
return normalizeMatchStartDebugSession({
outcome,
summary: cloneDebugValue(details, {}),
});
}
session.finishedAt = nowIso();
session.outcome = normalizeText(outcome || session.outcome || "completed") || "completed";
if (Object.prototype.hasOwnProperty.call(details, "lobbyId")) {
session.lobbyId = normalizeText(details.lobbyId || "") || null;
}
if (Object.prototype.hasOwnProperty.call(details, "payload")) {
session.payload = cloneDebugValue(details.payload, null);
}
if (Object.prototype.hasOwnProperty.call(details, "summary")) {
session.summary = cloneDebugValue(details.summary, {}) || {};
}
if (Object.prototype.hasOwnProperty.call(details, "cleanup")) {
session.cleanup = normalizeMatchStartCleanup(details.cleanup);
}
return normalizeMatchStartDebugSession(session);
}
function recordMatchStartDebugSession(store, session) {
if (!store || typeof store !== "object" || !session) {
return null;
}
const debugData = normalizeDebugData(store.debugData);
const normalizedSession = normalizeMatchStartDebugSession(session);
debugData.matchStartSessions.push(normalizedSession);
debugData.matchStartSessions = debugData.matchStartSessions.slice(-MATCH_START_DEBUG_SESSION_LIMIT);
store.debugData = debugData;
return normalizedSession;
}
function clearMatchStartDebugSessions(store) {
if (!store || typeof store !== "object") {
return;
}
store.debugData = createDefaultDebugData();
}
function getMatchStartDebugSessions(store) {
return normalizeDebugData(store?.debugData).matchStartSessions;
}
function buildMatchStartDebugReport(store, options = {}) {
const limit = clampInt(
options.limit,
MATCH_START_DEBUG_REPORT_SESSION_LIMIT,
1,
MATCH_START_DEBUG_SESSION_LIMIT,
);
const sessions = getMatchStartDebugSessions(store);
return {
appVersion: APP_VERSION,
generatedAt: nowIso(),
debugEnabled: Boolean(store?.settings?.debug),
sessionCount: sessions.length,
tournament: store?.tournament
? {
id: normalizeText(store.tournament.id || ""),
name: normalizeText(store.tournament.name || ""),
mode: normalizeText(store.tournament.mode || ""),
bestOfLegs: Number(store.tournament.bestOfLegs || 0),
startScore: Number(store.tournament.startScore || 0),
matchCount: Array.isArray(store.tournament.matches) ? store.tournament.matches.length : 0,
}
: null,
sessions: cloneDebugValue(sessions.slice(-limit), []) || [],
};
}
function serializeMatchStartError(error, extractErrorText = null) {
const fallbackText = typeof extractErrorText === "function"
? normalizeText(extractErrorText(error))
: "";
const message = normalizeText(error?.message || fallbackText || "Unbekannter API-Fehler.") || "Unbekannter API-Fehler.";
return {
name: normalizeText(error?.name || ""),
status: Number(error?.status || 0),
message,
body: cloneDebugValue(error?.body, null),
};
}
function shouldRetryLobbyCreateWithBullModeFallback(error, payload, extractErrorText = null) {
const status = Number(error?.status || 0);
const fallbackText = typeof extractErrorText === "function"
? extractErrorText(error)
: normalizeText(error?.message || "");
const errorText = normalizeLookup(fallbackText || error?.message || "");
const selectedBullMode = normalizeText(payload?.settings?.bullMode || "");
return status === 400 && errorText.includes("bull mode") && selectedBullMode !== "25/50";
}
function shouldCleanupFailedMatchStartLobby(lobbyId, startRequested) {
return Boolean(normalizeText(lobbyId || "")) && !Boolean(startRequested);
}
async function createLobbyWithBullModeFallback(lobbyPayload, token, deps = {}) {
const createLobbyFn = typeof deps.createLobby === "function" ? deps.createLobby : null;
if (!createLobbyFn) {
throw new Error("createLobby fehlt.");
}
const extractErrorText = typeof deps.extractErrorText === "function"
? deps.extractErrorText
: ((error) => normalizeText(error?.message || ""));
const emitStep = typeof deps.onStep === "function" ? deps.onStep : null;
let effectivePayload = cloneDebugValue(lobbyPayload, {}) || {};
try {
emitStep?.({
step: "create_lobby",
status: "pending",
details: { payload: effectivePayload },
});
const lobby = await createLobbyFn(effectivePayload, token);
emitStep?.({
step: "create_lobby",
status: "ok",
details: { payload: effectivePayload },
});
return {
lobby,
payload: effectivePayload,
usedBullModeFallback: false,
};
} catch (error) {
const errorInfo = serializeMatchStartError(error, extractErrorText);
const shouldRetry = shouldRetryLobbyCreateWithBullModeFallback(error, effectivePayload, extractErrorText);
emitStep?.({
step: "create_lobby",
status: "error",
details: {
error: errorInfo,
retryingBullModeFallback: shouldRetry,
},
});
if (!shouldRetry) {
throw error;
}
effectivePayload = cloneDebugValue(effectivePayload, {}) || {};
if (!effectivePayload.settings || typeof effectivePayload.settings !== "object") {
effectivePayload.settings = {};
}
effectivePayload.settings.bullMode = "25/50";
emitStep?.({
step: "create_lobby_retry",
status: "pending",
details: { payload: effectivePayload, reason: "bull_mode_fallback_25_50" },
});
const lobby = await createLobbyFn(effectivePayload, token);
emitStep?.({
step: "create_lobby_retry",
status: "ok",
details: { payload: effectivePayload, reason: "bull_mode_fallback_25_50" },
});
return {
lobby,
payload: effectivePayload,
usedBullModeFallback: true,
};
}
}
async function executeMatchStartApiFlow(input, deps = {}) {
const createLobbyFn = typeof deps.createLobby === "function" ? deps.createLobby : null;
const addLobbyPlayerFn = typeof deps.addLobbyPlayer === "function" ? deps.addLobbyPlayer : null;
const startLobbyFn = typeof deps.startLobby === "function" ? deps.startLobby : null;
if (!createLobbyFn || !addLobbyPlayerFn || !startLobbyFn) {
throw new Error("Matchstart-Flow benötigt createLobby, addLobbyPlayer und startLobby.");
}
const deleteLobbyFn = typeof deps.deleteLobby === "function" ? deps.deleteLobby : null;
const extractErrorText = typeof deps.extractErrorText === "function"
? deps.extractErrorText
: ((error) => normalizeText(error?.message || ""));
const emitStep = typeof deps.onStep === "function" ? deps.onStep : null;
const boardId = normalizeText(input?.boardId || "");
const token = normalizeText(input?.token || "");
const player1Name = normalizeText(input?.participant1Name || "");
const player2Name = normalizeText(input?.participant2Name || "");
let createdLobbyId = "";
let startRequested = false;
let usedBullModeFallback = false;
let effectivePayload = cloneDebugValue(input?.lobbyPayload, {}) || {};
emitStep?.({
step: "request_init",
status: "info",
details: {
matchId: normalizeText(input?.matchId || ""),
boardId,
participant1Name: player1Name,
participant2Name: player2Name,
payload: effectivePayload,
},
});
try {
const createOutcome = await createLobbyWithBullModeFallback(effectivePayload, token, {
createLobby: createLobbyFn,
extractErrorText,
onStep: emitStep,
});
effectivePayload = cloneDebugValue(createOutcome.payload, {}) || {};
usedBullModeFallback = Boolean(createOutcome.usedBullModeFallback);
createdLobbyId = normalizeText(createOutcome?.lobby?.id || createOutcome?.lobby?.uuid || "");
if (!createdLobbyId) {
throw new Error("Lobby konnte nicht erstellt werden (keine Lobby-ID).");
}
emitStep?.({
step: "lobby_created",
status: "ok",
details: {
lobbyId: createdLobbyId,
usedBullModeFallback,
},
});
emitStep?.({
step: "add_player_1",
status: "pending",
details: {
lobbyId: createdLobbyId,
name: player1Name,
boardId,
},
});
await addLobbyPlayerFn(createdLobbyId, player1Name, boardId, token);
emitStep?.({
step: "add_player_1",
status: "ok",
details: {
lobbyId: createdLobbyId,
name: player1Name,
boardId,
},
});
emitStep?.({
step: "add_player_2",
status: "pending",
details: {
lobbyId: createdLobbyId,
name: player2Name,
boardId,
},
});
await addLobbyPlayerFn(createdLobbyId, player2Name, boardId, token);
emitStep?.({
step: "add_player_2",
status: "ok",
details: {
lobbyId: createdLobbyId,
name: player2Name,
boardId,
},
});
emitStep?.({
step: "start_lobby",
status: "pending",
details: { lobbyId: createdLobbyId },
});
startRequested = true;
await startLobbyFn(createdLobbyId, token);
emitStep?.({
step: "start_lobby",
status: "ok",
details: { lobbyId: createdLobbyId },
});
return {
ok: true,
lobbyId: createdLobbyId,
effectivePayload,
usedBullModeFallback,
startRequested: true,
cleanup: normalizeMatchStartCleanup(null),
error: null,
};
} catch (error) {
const cleanup = normalizeMatchStartCleanup(null);
if (shouldCleanupFailedMatchStartLobby(createdLobbyId, startRequested) && deleteLobbyFn) {
cleanup.attempted = true;
emitStep?.({
step: "cleanup_lobby",
status: "pending",
details: { lobbyId: createdLobbyId },
});
try {
await deleteLobbyFn(createdLobbyId, token);
cleanup.ok = true;
cleanup.message = "Ungestartete Lobby wurde bereinigt.";
emitStep?.({
step: "cleanup_lobby",
status: "ok",
details: {
lobbyId: createdLobbyId,
message: cleanup.message,
},
});
} catch (cleanupError) {
cleanup.ok = false;
cleanup.message = normalizeText(extractErrorText(cleanupError) || cleanupError?.message || "Lobby-Cleanup fehlgeschlagen.")
|| "Lobby-Cleanup fehlgeschlagen.";
cleanup.error = serializeMatchStartError(cleanupError, extractErrorText);
emitStep?.({
step: "cleanup_lobby",
status: "error",
details: {
lobbyId: createdLobbyId,
error: cleanup.error,
},
});
}
} else {
cleanup.skippedReason = !createdLobbyId
? "no_lobby_created"
: (startRequested ? "start_already_requested" : "delete_lobby_unavailable");
emitStep?.({
step: "cleanup_lobby",
status: "skipped",
details: {
lobbyId: createdLobbyId || null,
skippedReason: cleanup.skippedReason,
},
});
}
emitStep?.({
step: "flow_error",
status: "error",
details: {
lobbyId: createdLobbyId || null,
error: serializeMatchStartError(error, extractErrorText),
cleanup,
},
});
return {
ok: false,
lobbyId: createdLobbyId || null,
effectivePayload,
usedBullModeFallback,
startRequested,
cleanup,
error,
};
}
}
function logDebug(category, message, ...args) {
if (!state.store.settings.debug) {
return;
}
console.info(`[ATA][${category}] ${message}`, ...args);
}
function logWarn(category, message, ...args) {
console.warn(`[ATA][${category}] ${message}`, ...args);
}
function logError(category, message, ...args) {
console.error(`[ATA][${category}] ${message}`, ...args);
}
// Data layer: persistence, migration and normalization.
function addCleanup(fn) {
state.cleanupStack.push(fn);
return fn;
}
function addListener(target, eventName, handler, options) {
target.addEventListener(eventName, handler, options);
addCleanup(() => target.removeEventListener(eventName, handler, options));
}
function addInterval(handler, ms) {
const handle = window.setInterval(handler, ms);
addCleanup(() => clearInterval(handle));
return handle;
}
function addObserver(observer) {
addCleanup(() => observer.disconnect());
return observer;
}
async function readStoreValue(key, fallbackValue) {
try {
if (typeof GM_getValue === "function") {
const value = await toPromise(GM_getValue(key, fallbackValue));
if (value !== undefined) {
return value;
}
}
} catch (error) {
logWarn("storage", `GM_getValue failed for ${key}, fallback to localStorage.`, error);
}
try {
const raw = localStorage.getItem(key);
if (raw !== null) {
return JSON.parse(raw);
}
} catch (error) {
logWarn("storage", `localStorage read failed for ${key}.`, error);
}
return fallbackValue;
}
async function writeStoreValue(key, value) {
try {
if (typeof GM_setValue === "function") {
await toPromise(GM_setValue(key, value));
}
} catch (error) {
logWarn("storage", `GM_setValue failed for ${key}, fallback to localStorage.`, error);
}
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
logWarn("storage", `localStorage write failed for ${key}.`, error);
}
}
// Source of truth for shipped presets:
// - "PDC European Tour (Official)" models the default round setup this project can represent honestly:
// KO, Best of 11 Legs, 501, Straight In, Double Out.
// - PDC World Championship style set-play is intentionally not shipped as an "official" preset here,
// because the AutoDarts lobby payload only supports legs/first-to-N, not sets.
function getCreatePresetDefinitions() {
return Object.freeze({
[X01_PRESET_PDC_EUROPEAN_TOUR_OFFICIAL]: Object.freeze({
id: X01_PRESET_PDC_EUROPEAN_TOUR_OFFICIAL,
label: "PDC European Tour (Official)",
shortLabel: "PDC European Tour",
description: "European Tour default round format: KO, Best of 11 Legs (First to 6), 501, Straight In, Double Out, Bull 25/50.",
notes: Object.freeze([
"Bull-off Normal is the AutoDarts mapping used by this preset.",
"Max Runden 50 remains a technical AutoDarts limit and is not part of the PDC rule claim.",
]),
apply: Object.freeze({
mode: "ko",
bestOfLegs: 11,
startScore: 501,
x01InMode: "Straight",
x01OutMode: "Double",
x01BullMode: "25/50",
x01BullOffMode: "Normal",
x01MaxRounds: 50,
lobbyVisibility: "private",
}),
}),
[X01_PRESET_PDC_501_DOUBLE_OUT_BASIC]: Object.freeze({
id: X01_PRESET_PDC_501_DOUBLE_OUT_BASIC,
label: "PDC 501 / Double Out (Basic)",
shortLabel: "PDC 501 / DO Basic",
description: "Compatibility preset for the former 'PDC-Standard': KO, Best of 5 Legs, 501, Straight In, Double Out, Bull 25/50.",
notes: Object.freeze([
"This is not an official PDC event format.",
"Kept to preserve older saved drafts and tournaments without silently changing their match length.",
]),
apply: Object.freeze({
mode: "ko",
bestOfLegs: 5,
startScore: 501,
x01InMode: "Straight",
x01OutMode: "Double",
x01BullMode: "25/50",
x01BullOffMode: "Normal",
x01MaxRounds: 50,
lobbyVisibility: "private",
}),
}),
});
}
function getCreatePresetOrder() {
return Object.freeze([
X01_PRESET_PDC_EUROPEAN_TOUR_OFFICIAL,
X01_PRESET_PDC_501_DOUBLE_OUT_BASIC,
]);
}
function getCreatePresetAliasMap() {
return Object.freeze({
[X01_PRESET_LEGACY_PDC_STANDARD]: X01_PRESET_PDC_501_DOUBLE_OUT_BASIC,
});
}
function getDefaultCreatePresetId() {
return X01_PRESET_PDC_EUROPEAN_TOUR_OFFICIAL;
}
function getCreatePresetDefinition(presetId) {
const presetDefinitions = getCreatePresetDefinitions();
const normalizedPreset = normalizeText(presetId || "").toLowerCase();
const canonicalPreset = getCreatePresetAliasMap()[normalizedPreset] || normalizedPreset;
return presetDefinitions[canonicalPreset] || null;
}
function getCreatePresetCatalog() {
const presetDefinitions = getCreatePresetDefinitions();
return getCreatePresetOrder().map((presetId) => presetDefinitions[presetId]);
}
function getCreatePresetLabel(presetId) {
return getCreatePresetDefinition(presetId)?.label || "Individuell";
}
function buildPresetX01Settings(presetId) {
const preset = getCreatePresetDefinition(presetId);
if (!preset) {
return null;
}
const apply = preset.apply;
return {
presetId: preset.id,
variant: X01_VARIANT,
baseScore: apply.startScore,
inMode: apply.x01InMode,
outMode: apply.x01OutMode,
bullMode: apply.x01BullMode,
maxRounds: apply.x01MaxRounds,
bullOffMode: apply.x01BullOffMode,
lobbyVisibility: apply.lobbyVisibility,
};
}
function buildExplicitX01Settings(rawInput, fallbackStartScore = 501) {
const input = rawInput && typeof rawInput === "object" ? rawInput : {};
return {
presetId: X01_PRESET_CUSTOM,
variant: X01_VARIANT,
baseScore: sanitizeStartScore(input.baseScore ?? fallbackStartScore),
inMode: sanitizeX01InMode(input.inMode),
outMode: sanitizeX01OutMode(input.outMode),
bullMode: sanitizeX01BullMode(input.bullMode),
maxRounds: sanitizeX01MaxRounds(input.maxRounds),
bullOffMode: sanitizeX01BullOffMode(input.bullOffMode || input.bullOff),
lobbyVisibility: sanitizeLobbyVisibility(input.lobbyVisibility ?? input.isPrivate),
};
}
function isSameX01Settings(left, right) {
return Boolean(left && right)
&& left.baseScore === right.baseScore
&& left.inMode === right.inMode
&& left.outMode === right.outMode
&& left.bullMode === right.bullMode
&& left.maxRounds === right.maxRounds
&& left.bullOffMode === right.bullOffMode
&& left.lobbyVisibility === right.lobbyVisibility;
}
function matchesPresetX01Settings(input, presetId) {
const presetX01 = buildPresetX01Settings(presetId);
if (!presetX01) {
return false;
}
const fallbackStartScore = input?.baseScore ?? input?.startScore ?? presetX01.baseScore;
const normalized = buildExplicitX01Settings({
baseScore: input?.baseScore ?? input?.startScore,
inMode: input?.inMode ?? input?.x01InMode,
outMode: input?.outMode ?? input?.x01OutMode,
bullMode: input?.bullMode ?? input?.x01BullMode,
maxRounds: input?.maxRounds ?? input?.x01MaxRounds,
bullOffMode: input?.bullOffMode ?? input?.x01BullOffMode,
lobbyVisibility: input?.lobbyVisibility,
}, fallbackStartScore);
return isSameX01Settings(normalized, presetX01);
}
function matchesCreatePresetSetup(input, presetId) {
const preset = getCreatePresetDefinition(presetId);
if (!preset) {
return false;
}
const apply = preset.apply;
const mode = normalizeText(input?.mode || "").toLowerCase();
if (mode !== apply.mode) {
return false;
}
if (sanitizeBestOf(input?.bestOfLegs) !== apply.bestOfLegs) {
return false;
}
const x01Input = input?.x01 && typeof input.x01 === "object"
? input.x01
: input;
return matchesPresetX01Settings(x01Input, preset.id);
}
function validateCreatePresetDefinitions() {
return getCreatePresetCatalog().map((preset) => {
const apply = preset.apply || {};
const issues = [];
const normalizedX01 = buildExplicitX01Settings({
baseScore: apply.startScore,
inMode: apply.x01InMode,
outMode: apply.x01OutMode,
bullMode: apply.x01BullMode,
maxRounds: apply.x01MaxRounds,
bullOffMode: apply.x01BullOffMode,
lobbyVisibility: apply.lobbyVisibility,
}, apply.startScore);
if (!normalizeText(preset.id)) {
issues.push("missing id");
}
if (!normalizeText(preset.label)) {
issues.push("missing label");
}
if (!normalizeText(preset.description)) {
issues.push("missing description");
}
if (!Array.isArray(preset.notes) || !preset.notes.length) {
issues.push("missing notes");
}
if (apply.mode !== "ko") {
issues.push("mode must be ko");
}
if (sanitizeBestOf(apply.bestOfLegs) !== apply.bestOfLegs) {
issues.push("invalid bestOfLegs");
}
if (normalizedX01.baseScore !== apply.startScore) {
issues.push("invalid startScore");
}
if (normalizedX01.inMode !== apply.x01InMode) {
issues.push("invalid in mode");
}
if (normalizedX01.outMode !== apply.x01OutMode) {
issues.push("invalid out mode");
}
if (normalizedX01.bullMode !== apply.x01BullMode) {
issues.push("invalid bull mode");
}
if (normalizedX01.maxRounds !== apply.x01MaxRounds) {
issues.push("invalid max rounds");
}
if (normalizedX01.bullOffMode !== apply.x01BullOffMode) {
issues.push("invalid bull-off mode");
}
if (normalizedX01.lobbyVisibility !== apply.lobbyVisibility) {
issues.push("invalid lobby visibility");
}
if (!isSameX01Settings(buildPresetX01Settings(preset.id), normalizedX01)) {
issues.push("preset x01 projection failed");
}
return {
id: preset.id,
ok: issues.length === 0,
issues,
};
});
}
function createDefaultCreateDraft(settings = null) {
const defaultRandomize = settings?.featureFlags?.randomizeKoRound1 !== false;
const defaultPresetId = getDefaultCreatePresetId();
const presetX01 = buildPresetX01Settings(defaultPresetId);
const apply = getCreatePresetDefinition(defaultPresetId)?.apply || {};
return {
name: "",
mode: apply.mode || "ko",
bestOfLegs: apply.bestOfLegs || 11,
startScore: presetX01?.baseScore || 501,
x01Preset: defaultPresetId,
x01InMode: presetX01?.inMode || "Straight",
x01OutMode: presetX01?.outMode || "Double",
x01BullMode: presetX01?.bullMode || "25/50",
x01MaxRounds: presetX01?.maxRounds || 50,
x01BullOffMode: presetX01?.bullOffMode || "Normal",
lobbyVisibility: presetX01?.lobbyVisibility || "private",
boardCount: TOURNAMENT_DURATION_DEFAULT_BOARD_COUNT,
participantsText: "",
randomizeKoRound1: Boolean(defaultRandomize),
enableThirdPlaceMatch: false,
};
}
function normalizeCreateDraft(rawDraft, settings = null) {
const base = createDefaultCreateDraft(settings);
const hasDraftObject = rawDraft && typeof rawDraft === "object";
const hasExplicitPreset = hasDraftObject && Object.prototype.hasOwnProperty.call(rawDraft, "x01Preset");
const requestedPresetId = hasExplicitPreset
? sanitizeX01Preset(rawDraft?.x01Preset, X01_PRESET_CUSTOM)
: (hasDraftObject ? X01_PRESET_CUSTOM : base.x01Preset);
const requestedPreset = getCreatePresetDefinition(requestedPresetId);
const presetApply = requestedPreset?.apply || null;
const modeFallback = presetApply?.mode || base.mode;
const modeRaw = normalizeText(rawDraft?.mode ?? modeFallback);
const mode = ["ko", "league", "groups_ko"].includes(modeRaw) ? modeRaw : modeFallback;
const bestOfFallback = presetApply?.bestOfLegs ?? base.bestOfLegs;
const startScoreFallback = presetApply?.startScore ?? base.startScore;
const x01Settings = normalizeTournamentX01Settings({
presetId: X01_PRESET_CUSTOM,
baseScore: rawDraft?.startScore ?? startScoreFallback,
inMode: rawDraft?.x01InMode ?? presetApply?.x01InMode ?? base.x01InMode,
outMode: rawDraft?.x01OutMode ?? presetApply?.x01OutMode ?? base.x01OutMode,
bullMode: rawDraft?.x01BullMode ?? presetApply?.x01BullMode ?? base.x01BullMode,
maxRounds: rawDraft?.x01MaxRounds ?? presetApply?.x01MaxRounds ?? base.x01MaxRounds,
bullOffMode: rawDraft?.x01BullOffMode ?? presetApply?.x01BullOffMode ?? base.x01BullOffMode,
lobbyVisibility: rawDraft?.lobbyVisibility ?? presetApply?.lobbyVisibility ?? base.lobbyVisibility,
}, rawDraft?.startScore ?? startScoreFallback);
const draft = {
name: normalizeText(rawDraft?.name || base.name),
mode,
bestOfLegs: sanitizeBestOf(rawDraft?.bestOfLegs ?? bestOfFallback),
startScore: x01Settings.baseScore,
x01Preset: X01_PRESET_CUSTOM,
x01InMode: x01Settings.inMode,
x01OutMode: x01Settings.outMode,
x01BullMode: x01Settings.bullMode,
x01MaxRounds: x01Settings.maxRounds,
x01BullOffMode: x01Settings.bullOffMode,
lobbyVisibility: x01Settings.lobbyVisibility,
boardCount: sanitizeTournamentBoardCount(rawDraft?.boardCount, base.boardCount),
participantsText: String(rawDraft?.participantsText ?? base.participantsText),
randomizeKoRound1: typeof rawDraft?.randomizeKoRound1 === "boolean"
? rawDraft.randomizeKoRound1
: base.randomizeKoRound1,
enableThirdPlaceMatch: typeof rawDraft?.enableThirdPlaceMatch === "boolean"
? rawDraft.enableThirdPlaceMatch
: base.enableThirdPlaceMatch,
};
if (requestedPreset && matchesCreatePresetSetup(draft, requestedPreset.id)) {
draft.x01Preset = requestedPreset.id;
} else if (!hasDraftObject) {
draft.x01Preset = base.x01Preset;
}
return draft;
}
function createDefaultStore() {
const settings = {
debug: false,
tournamentTimeProfile: TOURNAMENT_TIME_PROFILE_NORMAL,
featureFlags: {
autoLobbyStart: false,
randomizeKoRound1: true,
koDrawLockDefault: true,
},
};
return {
schemaVersion: STORAGE_SCHEMA_VERSION,
settings,
ui: {
activeTab: "tournament",
matchesSortMode: MATCH_SORT_MODE_READY_FIRST,
durationEstimateVisible: true,
createDraft: createDefaultCreateDraft(settings),
},
debugData: createDefaultDebugData(),
tournament: null,
};
}
function normalizeKoDrawMode(value, fallback = KO_DRAW_MODE_SEEDED) {
const mode = normalizeText(value || "").toLowerCase();
if (mode === KO_DRAW_MODE_OPEN_DRAW || mode === KO_DRAW_MODE_SEEDED) {
return mode;
}
return fallback;
}
function normalizeKoEngineVersion(value, fallback = 0) {
const parsed = clampInt(value, fallback, 0, KO_ENGINE_VERSION);
return parsed > KO_ENGINE_VERSION ? KO_ENGINE_VERSION : parsed;
}
function normalizeMatchResultKind(value) {
const normalized = normalizeText(value || "").toLowerCase();
return normalized === "bye" ? "bye" : null;
}
function isByePlaceholderValue(value) {
const token = normalizeToken(value);
return Boolean(token) && BYE_PLACEHOLDER_TOKENS.has(token);
}
function sanitizeBestOf(value) {
let bestOf = clampInt(value, 5, 1, 21);
if (bestOf % 2 === 0) {
bestOf += 1;
}
return bestOf;
}
function sanitizeStartScore(value) {
const allowed = new Set(X01_START_SCORE_OPTIONS);
const score = clampInt(value, 501, 121, 901);
return allowed.has(score) ? score : 501;
}
function getLegsToWin(bestOfLegs) {
const bestOf = sanitizeBestOf(bestOfLegs);
return Math.floor(bestOf / 2) + 1;
}
function sanitizeX01Preset(value, fallback = getDefaultCreatePresetId()) {
const preset = normalizeText(value || "").toLowerCase();
if (preset === X01_PRESET_CUSTOM) {
return preset;
}
const canonicalPreset = getCreatePresetAliasMap()[preset] || preset;
if (getCreatePresetDefinitions()[canonicalPreset]) {
return canonicalPreset;
}
return fallback;
}
function sanitizeX01Mode(value, allowedModes, fallback) {
const mode = normalizeText(value || "");
return allowedModes.includes(mode) ? mode : fallback;
}
function sanitizeX01InMode(value) {
return sanitizeX01Mode(value, X01_IN_MODES, "Straight");
}
function sanitizeX01OutMode(value) {
return sanitizeX01Mode(value, X01_OUT_MODES, "Double");
}
function sanitizeX01BullMode(value) {
return sanitizeX01Mode(value, X01_BULL_MODES, "25/50");
}
function sanitizeX01BullOffMode(value) {
return sanitizeX01Mode(value, X01_BULL_OFF_MODES, "Normal");
}
function sanitizeX01MaxRounds(value) {
const rounds = clampInt(value, 50, 15, 80);
return X01_MAX_ROUNDS_OPTIONS.includes(rounds) ? rounds : 50;
}
function sanitizeTournamentTimeProfile(value, fallback = TOURNAMENT_TIME_PROFILE_NORMAL) {
const profile = normalizeText(value || "").toLowerCase();
return TOURNAMENT_TIME_PROFILES.includes(profile) ? profile : fallback;
}
function sanitizeTournamentBoardCount(value, fallback = TOURNAMENT_DURATION_DEFAULT_BOARD_COUNT) {
return clampInt(value, fallback, 1, TOURNAMENT_DURATION_MAX_BOARD_COUNT);
}
function normalizeTournamentDurationMeta(rawDuration) {
const duration = rawDuration && typeof rawDuration === "object" ? rawDuration : {};
return {
boardCount: sanitizeTournamentBoardCount(
duration.boardCount,
TOURNAMENT_DURATION_DEFAULT_BOARD_COUNT,
),
};
}
function sanitizeMatchesSortMode(value, fallback = MATCH_SORT_MODE_READY_FIRST) {
const mode = normalizeText(value || "").toLowerCase();
return MATCH_SORT_MODES.includes(mode) ? mode : fallback;
}
function sanitizeLobbyVisibility(value) {
void value;
return "private";
}
function mapLegacyTieBreakModeToProfile(value, fallback = TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE) {
const mode = normalizeText(value || "").toLowerCase();
if (mode === LEGACY_TIE_BREAK_MODE_DRA_STRICT) {
return TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE;
}
if (mode === LEGACY_TIE_BREAK_MODE_LEGACY) {
return TIE_BREAK_PROFILE_PROMOTER_POINTS_LEGDIFF;
}
return fallback;
}
function normalizeTieBreakProfile(value, fallback = TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE) {
const profile = normalizeText(value || "").toLowerCase();
if (TIE_BREAK_PROFILES.includes(profile)) {
return profile;
}
return mapLegacyTieBreakModeToProfile(value, fallback);
}
function normalizeTournamentRules(rawRules) {
const rules = rawRules && typeof rawRules === "object" ? rawRules : {};
const tieBreakRaw = Object.prototype.hasOwnProperty.call(rules, "tieBreakProfile")
? rules.tieBreakProfile
: rules.tieBreakMode;
return {
tieBreakProfile: normalizeTieBreakProfile(tieBreakRaw, TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE),
};
}
function normalizeTournamentX01Settings(rawX01, fallbackStartScore = 501) {
const hasRawObject = rawX01 && typeof rawX01 === "object";
const input = hasRawObject ? rawX01 : {};
const rawPreset = normalizeText(input.presetId || input.preset || "").toLowerCase();
const hasExplicitPreset = Boolean(rawPreset);
const presetId = hasExplicitPreset ? sanitizeX01Preset(rawPreset, X01_PRESET_CUSTOM) : X01_PRESET_CUSTOM;
const presetX01 = buildPresetX01Settings(presetId);
const normalized = buildExplicitX01Settings({
baseScore: input.baseScore ?? presetX01?.baseScore ?? fallbackStartScore,
inMode: input.inMode ?? presetX01?.inMode,
outMode: input.outMode ?? presetX01?.outMode,
bullMode: input.bullMode ?? presetX01?.bullMode,
maxRounds: input.maxRounds ?? presetX01?.maxRounds,
bullOffMode: input.bullOffMode || input.bullOff || presetX01?.bullOffMode,
lobbyVisibility: input.lobbyVisibility ?? input.isPrivate ?? presetX01?.lobbyVisibility,
}, input.baseScore ?? presetX01?.baseScore ?? fallbackStartScore);
if (presetX01 && isSameX01Settings(normalized, presetX01)) {
return {
...presetX01,
presetId,
};
}
return normalized;
}
function isEuropeanTourOfficialMatchSetup(input) {
return matchesCreatePresetSetup(input, X01_PRESET_PDC_EUROPEAN_TOUR_OFFICIAL);
}
function getAppliedCreatePresetId(input) {
const requestedPresetId = sanitizeX01Preset(
input?.x01Preset ?? input?.presetId ?? input?.x01?.presetId,
X01_PRESET_CUSTOM,
);
const requestedPreset = getCreatePresetDefinition(requestedPresetId);
if (!requestedPreset) {
return X01_PRESET_CUSTOM;
}
return matchesCreatePresetSetup(input, requestedPreset.id)
? requestedPreset.id
: X01_PRESET_CUSTOM;
}
function normalizeAutomationStatus(value, fallback = "idle") {
return ["idle", "started", "completed", "error"].includes(value) ? value : fallback;
}
function normalizeAutomationMeta(rawAuto) {
const auto = rawAuto && typeof rawAuto === "object" ? rawAuto : {};
const lobbyId = normalizeText(auto.lobbyId || "");
let status = normalizeAutomationStatus(normalizeText(auto.status || ""), lobbyId ? "started" : "idle");
if (!lobbyId && status !== "error") {
status = "idle";
}
return {
provider: API_PROVIDER,
lobbyId: lobbyId || null,
status,
startedAt: normalizeText(auto.startedAt || "") || null,
finishedAt: normalizeText(auto.finishedAt || "") || null,
lastSyncAt: normalizeText(auto.lastSyncAt || "") || null,
lastError: normalizeText(auto.lastError || "") || null,
};
}
function normalizeStoredMatchAverage(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number.parseFloat(String(value));
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 200) {
return null;
}
return Math.round(parsed * 100) / 100;
}
function normalizeStoredMatchHighFinish(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = clampInt(value, null, 1, 170);
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeStoredPlayerStats(rawStats) {
return {
average: normalizeStoredMatchAverage(rawStats?.average),
oneEighties: clampInt(rawStats?.oneEighties, 0, 0, 99),
highFinish: normalizeStoredMatchHighFinish(rawStats?.highFinish),
};
}
function normalizeStoredMatchStats(rawStats) {
return {
p1: normalizeStoredPlayerStats(rawStats?.p1),
p2: normalizeStoredPlayerStats(rawStats?.p2),
};
}
function resetMatchAutomationMeta(match) {
const auto = ensureMatchAutoMeta(match);
auto.lobbyId = null;
auto.status = "idle";
auto.startedAt = null;
auto.finishedAt = null;
auto.lastSyncAt = null;
auto.lastError = null;
return auto;
}
function normalizeMatchMeta(rawMeta) {
const meta = rawMeta && typeof rawMeta === "object" ? rawMeta : {};
const resultKind = normalizeMatchResultKind(meta.resultKind);
return {
...meta,
resultKind,
auto: normalizeAutomationMeta(meta.auto),
};
}
function ensureMatchMeta(match) {
if (!match || typeof match !== "object") {
return normalizeMatchMeta(null);
}
if (!match.meta || typeof match.meta !== "object") {
match.meta = {};
}
match.meta = normalizeMatchMeta(match.meta);
return match.meta;
}
function setMatchResultKind(match, resultKind) {
const meta = ensureMatchMeta(match);
const nextKind = normalizeMatchResultKind(resultKind);
if (meta.resultKind === nextKind) {
return false;
}
meta.resultKind = nextKind;
return true;
}
function isByeMatchResult(match) {
return normalizeMatchResultKind(match?.meta?.resultKind) === "bye";
}
function ensureMatchAutoMeta(match) {
const meta = ensureMatchMeta(match);
meta.auto = normalizeAutomationMeta(meta.auto);
return meta.auto;
}
function normalizeKoVirtualMatch(rawMatch, roundFallback, indexFallback) {
const matchRole = normalizeText(rawMatch?.matchRole || "").toLowerCase() === "third_place"
? "third_place"
: "main";
return {
id: normalizeText(rawMatch?.id || `ko-r${roundFallback}-m${indexFallback}`),
round: clampInt(rawMatch?.round, roundFallback, 1, 64),
number: clampInt(rawMatch?.number, indexFallback, 1, 256),
structuralBye: Boolean(rawMatch?.structuralBye),
matchRole,
advancesWinnerTo: normalizeText(rawMatch?.advancesWinnerTo || "") || null,
advancesLoserTo: normalizeText(rawMatch?.advancesLoserTo || "") || null,
placementRank: Number.isFinite(Number(rawMatch?.placementRank))
? clampInt(rawMatch?.placementRank, null, 1, 128)
: null,
competitors: {
p1: rawMatch?.competitors?.p1 || null,
p2: rawMatch?.competitors?.p2 || null,
},
};
}
function normalizeKoRoundStructure(rawRound, roundFallback, totalRounds = roundFallback) {
const virtualMatchesRaw = Array.isArray(rawRound?.virtualMatches) ? rawRound.virtualMatches : [];
return {
round: clampInt(rawRound?.round, roundFallback, 1, 64),
label: normalizeText(rawRound?.label || getKoRoundLabel(roundFallback, totalRounds)),
virtualMatches: virtualMatchesRaw.map((entry, index) => (
normalizeKoVirtualMatch(entry, roundFallback, index + 1)
)),
};
}
function normalizeKoSeedEntry(rawSeed, indexFallback) {
const participantId = normalizeText(rawSeed?.participantId || rawSeed?.id || "");
if (!participantId) {
return null;
}
return {
participantId,
participantName: normalizeText(rawSeed?.participantName || rawSeed?.name || participantId),
seed: clampInt(rawSeed?.seed, indexFallback, 1, TECHNICAL_PARTICIPANT_HARD_MAX),
hasBye: Boolean(rawSeed?.hasBye),
entryRound: clampInt(rawSeed?.entryRound, rawSeed?.hasBye ? 2 : 1, 1, 64),
slot: Number.isFinite(Number(rawSeed?.slot))
? clampInt(rawSeed?.slot, null, 1, TECHNICAL_PARTICIPANT_HARD_MAX)
: null,
};
}
function normalizeKoDrawLocked(value, fallback = true) {
if (typeof value === "boolean") {
return value;
}
return Boolean(fallback);
}
function normalizeKoPlacement(placementRaw, bracketSize) {
const fallbackPlacement = buildSeedPlacement(bracketSize);
if (!Array.isArray(placementRaw) || !placementRaw.length) {
return fallbackPlacement;
}
const used = new Set();
const normalized = placementRaw
.map((entry) => clampInt(entry, null, 1, bracketSize))
.filter((entry) => Number.isInteger(entry) && !used.has(entry) && used.add(entry));
if (normalized.length !== bracketSize) {
return fallbackPlacement;
}
return normalized;
}
function normalizeTournamentKoMeta(rawKo, fallbackDrawMode = KO_DRAW_MODE_SEEDED, fallbackDrawLocked = true) {
const ko = rawKo && typeof rawKo === "object" ? rawKo : {};
const drawMode = normalizeKoDrawMode(ko.drawMode, fallbackDrawMode);
const drawLocked = normalizeKoDrawLocked(ko.drawLocked, fallbackDrawLocked);
const engineVersion = normalizeKoEngineVersion(ko.engineVersion, 0);
const seeding = (Array.isArray(ko.seeding) ? ko.seeding : [])
.map((entry, index) => normalizeKoSeedEntry(entry, index + 1))
.filter(Boolean);
const rounds = (Array.isArray(ko.rounds) ? ko.rounds : [])
.map((entry, index, array) => normalizeKoRoundStructure(entry, index + 1, array.length || index + 1));
const fallbackBracketSize = nextPowerOfTwo(Math.max(2, seeding.length));
const bracketSize = nextPowerOfTwo(clampInt(
ko.bracketSize,
fallbackBracketSize,
2,
TECHNICAL_PARTICIPANT_HARD_MAX,
));
const placement = normalizeKoPlacement(ko.placement, bracketSize);
return {
drawMode,
drawLocked,
enableThirdPlaceMatch: Boolean(ko.enableThirdPlaceMatch),
engineVersion,
bracketSize,
placement,
seeding,
rounds,
};
}
function normalizeTournamentResultEntry(rawResult, indexFallback) {
return {
matchId: normalizeText(rawResult?.matchId || rawResult?.id || `result-${indexFallback}`),
stage: [MATCH_STAGE_KO, MATCH_STAGE_GROUP, MATCH_STAGE_LEAGUE].includes(rawResult?.stage)
? rawResult.stage
: MATCH_STAGE_KO,
round: clampInt(rawResult?.round, 1, 1, 64),
number: clampInt(rawResult?.number, indexFallback, 1, 256),
player1Id: rawResult?.player1Id ? normalizeText(rawResult.player1Id) : null,
player2Id: rawResult?.player2Id ? normalizeText(rawResult.player2Id) : null,
winnerId: rawResult?.winnerId ? normalizeText(rawResult.winnerId) : null,
legs: {
p1: clampInt(rawResult?.legs?.p1, 0, 0, 99),
p2: clampInt(rawResult?.legs?.p2, 0, 0, 99),
},
stats: normalizeStoredMatchStats(rawResult?.stats),
source: rawResult?.source === "auto" ? "auto" : "manual",
updatedAt: normalizeText(rawResult?.updatedAt || nowIso()),
};
}
function normalizeTournament(rawTournament, fallbackKoDrawLocked = true) {
if (!rawTournament || typeof rawTournament !== "object") {
return null;
}
const mode = ["ko", "league", "groups_ko"].includes(rawTournament.mode) ? rawTournament.mode : "ko";
const modeLimits = getModeParticipantLimits(mode);
const participantsRaw = Array.isArray(rawTournament.participants) ? rawTournament.participants : [];
const participants = participantsRaw
.map((entry, index) => {
const name = normalizeText(entry?.name || entry || "");
if (!name) {
return null;
}
const id = normalizeText(entry?.id || `p-${index + 1}`);
return { id, name };
})
.filter(Boolean)
.slice(0, TECHNICAL_PARTICIPANT_HARD_MAX);
if (participants.length < modeLimits.min) {
return null;
}
const groupsRaw = Array.isArray(rawTournament.groups) ? rawTournament.groups : [];
const groups = groupsRaw.map((group, index) => ({
id: normalizeText(group?.id || `G${index + 1}`),
name: normalizeText(group?.name || `Gruppe ${index + 1}`),
participantIds: Array.isArray(group?.participantIds)
? group.participantIds.map((id) => normalizeText(id)).filter(Boolean)
: [],
}));
const matchesRaw = Array.isArray(rawTournament.matches) ? rawTournament.matches : [];
const matches = matchesRaw.map((match, index) => ({
id: normalizeText(match?.id || `match-${index + 1}`),
stage: [MATCH_STAGE_KO, MATCH_STAGE_GROUP, MATCH_STAGE_LEAGUE].includes(match?.stage) ? match.stage : MATCH_STAGE_KO,
round: clampInt(match?.round, 1, 1, 64),
number: clampInt(match?.number, index + 1, 1, 256),
groupId: match?.groupId ? normalizeText(match.groupId) : null,
player1Id: match?.player1Id ? normalizeText(match.player1Id) : null,
player2Id: match?.player2Id ? normalizeText(match.player2Id) : null,
status: match?.status === STATUS_COMPLETED ? STATUS_COMPLETED : STATUS_PENDING,
winnerId: match?.winnerId ? normalizeText(match.winnerId) : null,
source: match?.source === "auto" || match?.source === "manual" ? match.source : null,
legs: {
p1: clampInt(match?.legs?.p1, 0, 0, 50),
p2: clampInt(match?.legs?.p2, 0, 0, 50),
},
stats: normalizeStoredMatchStats(match?.stats),
updatedAt: normalizeText(match?.updatedAt || nowIso()),
meta: normalizeMatchMeta(match?.meta),
}));
const resultsRaw = Array.isArray(rawTournament.results) ? rawTournament.results : [];
const results = resultsRaw.map((entry, index) => normalizeTournamentResultEntry(entry, index + 1));
const fallbackStartScore = sanitizeStartScore(rawTournament.startScore);
const x01 = normalizeTournamentX01Settings(rawTournament.x01, fallbackStartScore);
const rules = normalizeTournamentRules(rawTournament.rules);
const duration = normalizeTournamentDurationMeta(rawTournament.duration);
return {
id: normalizeText(rawTournament.id || uuid("tournament")),
name: normalizeText(rawTournament.name || "Lokales Turnier"),
mode,
ko: mode === "ko"
? normalizeTournamentKoMeta(rawTournament.ko, KO_DRAW_MODE_SEEDED, fallbackKoDrawLocked)
: null,
bestOfLegs: sanitizeBestOf(rawTournament.bestOfLegs),
startScore: x01.baseScore,
x01,
rules,
duration,
participants,
groups,
matches,
results,
createdAt: normalizeText(rawTournament.createdAt || nowIso()),
updatedAt: normalizeText(rawTournament.updatedAt || nowIso()),
};
}
function normalizeStoreShape(input) {
const defaults = createDefaultStore();
const defaultKoDrawLocked = input?.settings?.featureFlags?.koDrawLockDefault !== false;
const settings = {
debug: Boolean(input?.settings?.debug),
tournamentTimeProfile: sanitizeTournamentTimeProfile(
input?.settings?.tournamentTimeProfile,
defaults.settings.tournamentTimeProfile,
),
featureFlags: {
autoLobbyStart: Boolean(input?.settings?.featureFlags?.autoLobbyStart),
randomizeKoRound1: input?.settings?.featureFlags?.randomizeKoRound1 !== false,
koDrawLockDefault: defaultKoDrawLocked,
},
};
return {
schemaVersion: STORAGE_SCHEMA_VERSION,
settings,
ui: {
activeTab: TAB_IDS.includes(input?.ui?.activeTab) ? input.ui.activeTab : defaults.ui.activeTab,
matchesSortMode: sanitizeMatchesSortMode(input?.ui?.matchesSortMode, defaults.ui.matchesSortMode),
durationEstimateVisible: input?.ui?.durationEstimateVisible !== false,
createDraft: normalizeCreateDraft(input?.ui?.createDraft, settings),
},
debugData: normalizeDebugData(input?.debugData),
tournament: normalizeTournament(input?.tournament, defaultKoDrawLocked),
};
}
function participantById(tournament, participantId) {
return tournament?.participants?.find((participant) => participant.id === participantId) || null;
}
function participantNameById(tournament, participantId) {
if (!participantId) {
return "\u2205 offen";
}
const participant = participantById(tournament, participantId);
return participant ? participant.name : "\u2205 offen";
}
function buildParticipantIndexes(tournament) {
const byId = new Map();
const byName = new Map();
(tournament?.participants || []).forEach((participant) => {
const id = normalizeText(participant?.id || "");
if (!id) {
return;
}
byId.set(id, participant);
const key = normalizeLookup(participant?.name || "");
if (key && !byName.has(key)) {
byName.set(key, id);
}
});
return { byId, byName };
}
function resolveParticipantSlotId(tournament, rawValue, indexes = null) {
const value = normalizeText(rawValue || "");
if (!value || isByePlaceholderValue(value)) {
return null;
}
const participantIndexes = indexes || buildParticipantIndexes(tournament);
if (participantIndexes.byId.has(value)) {
return value;
}
const mappedByName = participantIndexes.byName.get(normalizeLookup(value));
return mappedByName || null;
}
// Logic layer: deterministic tournament and bracket calculations.
function getModeParticipantLimits(mode) {
return MODE_PARTICIPANT_LIMITS[mode] || MODE_PARTICIPANT_LIMITS.ko;
}
function buildModeParticipantLimitSummary() {
return Object.entries(MODE_PARTICIPANT_LIMITS)
.map(([, limits]) => `${limits.label}: ${limits.min}-${limits.max}`)
.join(", ");
}
function getParticipantCountError(mode, count) {
const limits = getModeParticipantLimits(mode);
const participantCount = Number(count || 0);
if (participantCount < limits.min || participantCount > limits.max) {
return `${limits.label} erfordert ${limits.min}-${limits.max} Teilnehmer.`;
}
return "";
}
async function persistKoMigrationBackup(tournamentSnapshot, reason = "ko-engine-v2-migration") {
const snapshot = cloneSerializable(tournamentSnapshot);
if (!snapshot) {
return false;
}
const backupsRaw = await readStoreValue(STORAGE_KO_MIGRATION_BACKUPS_KEY, []);
const backups = Array.isArray(backupsRaw) ? backupsRaw : [];
backups.unshift({
id: uuid("ko-backup"),
reason: normalizeText(reason) || "ko-engine-v2-migration",
createdAt: nowIso(),
schemaVersion: STORAGE_SCHEMA_VERSION,
tournament: snapshot,
});
const limitedBackups = backups.slice(0, 5);
await writeStoreValue(STORAGE_KO_MIGRATION_BACKUPS_KEY, limitedBackups);
return true;
}
function migrateStorage(rawValue) {
if (!rawValue || typeof rawValue !== "object") {
return createDefaultStore();
}
const version = Number(rawValue.schemaVersion || 0);
switch (version) {
case 4:
case 3:
case 2:
case 1:
return normalizeStoreShape({
...rawValue,
tournament: rawValue.tournament
? {
...rawValue.tournament,
rules: normalizeTournamentRules(rawValue.tournament.rules),
}
: rawValue.tournament,
});
default:
if (rawValue.mode && rawValue.participants) {
return normalizeStoreShape({
tournament: {
...rawValue,
rules: normalizeTournamentRules(rawValue.rules),
},
});
}
return createDefaultStore();
}
}
function clearMatchResult(match) {
match.status = STATUS_PENDING;
match.winnerId = null;
match.source = null;
match.legs = { p1: 0, p2: 0 };
match.stats = normalizeMatchStats(null);
setMatchResultKind(match, null);
resetMatchAutomationMeta(match);
match.updatedAt = nowIso();
}
function assignPlayerSlot(match, slot, participantId) {
const field = slot === 1 ? "player1Id" : "player2Id";
const currentValue = match[field] || null;
const nextValue = participantId || null;
if (currentValue === nextValue) {
return false;
}
match[field] = nextValue;
const hasStoredResult = match.status === STATUS_COMPLETED
|| Boolean(match.winnerId || match.source || match.legs?.p1 || match.legs?.p2);
if (hasStoredResult) {
clearMatchResult(match);
}
match.updatedAt = nowIso();
return true;
}
function hasRelevantCompletedTieBreakMatch(tournament) {
const matches = Array.isArray(tournament?.matches) ? tournament.matches : [];
return matches.some((match) => (
match?.status === STATUS_COMPLETED
&& (match.stage === MATCH_STAGE_GROUP || match.stage === MATCH_STAGE_LEAGUE)
));
}
function applyTournamentTieBreakProfile(tournament, profile) {
if (!tournament) {
return { ok: false, message: "Kein aktives Turnier vorhanden." };
}
const nextProfile = normalizeTieBreakProfile(profile, TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE);
const currentProfile = normalizeTieBreakProfile(
tournament?.rules?.tieBreakProfile,
TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE,
);
if (nextProfile === currentProfile) {
return { ok: true, changed: false };
}
if (hasRelevantCompletedTieBreakMatch(tournament)) {
return {
ok: false,
changed: false,
reasonCode: "tie_break_locked",
message: `Tie-Break-Profil ist gesperrt, sobald ein Gruppen- oder Liga-Ergebnis abgeschlossen wurde (DRA 6.16.1). Siehe: ${DRA_GUI_RULE_TIE_BREAK_URL}`,
};
}
tournament.rules = normalizeTournamentRules({
...(tournament.rules || {}),
tieBreakProfile: nextProfile,
});
return { ok: true, changed: true };
}
function applyTournamentKoDrawLocked(tournament, drawLocked, options = {}) {
if (!tournament) {
return { ok: false, message: "Kein aktives Turnier vorhanden." };
}
if (tournament.mode !== "ko") {
return { ok: false, message: "Draw-Lock ist nur im KO-Modus verfügbar." };
}
const nextDrawLocked = Boolean(drawLocked);
const currentDrawLocked = tournament?.ko?.drawLocked !== false;
if (nextDrawLocked === currentDrawLocked) {
return { ok: true, changed: false };
}
const allowUnlockOverride = Boolean(options?.allowUnlockOverride);
if (!nextDrawLocked && currentDrawLocked && !allowUnlockOverride) {
return {
ok: false,
changed: false,
reasonCode: "draw_unlock_requires_override",
message: `Draw-Lock bleibt aktiv. Zum Entsperren ist ein expliziter Promoter-Override erforderlich (DRA 6.12.1). Siehe: ${DRA_GUI_RULE_DRAW_LOCK_URL}`,
};
}
tournament.ko = normalizeTournamentKoMeta({
...(tournament.ko || {}),
drawLocked: nextDrawLocked,
}, normalizeKoDrawMode(tournament?.ko?.drawMode, KO_DRAW_MODE_SEEDED), nextDrawLocked);
return { ok: true, changed: true };
}
/**
* @typedef {Object} KoSeed
* @property {string} participantId
* @property {string} participantName
* @property {number} seed
* @property {boolean} hasBye
* @property {number} entryRound
* @property {number|null} slot
*/
/**
* @typedef {Object} KoVirtualMatch
* @property {string} id
* @property {number} round
* @property {number} number
* @property {boolean} structuralBye
* @property {"main"|"third_place"} matchRole
* @property {string|null} advancesWinnerTo
* @property {string|null} advancesLoserTo
* @property {number|null} placementRank
* @property {Object} competitors
* @property {Object|null} competitors.p1
* @property {Object|null} competitors.p2
*/
/**
* @typedef {Object} KoBracketStructure
* @property {number} bracketSize
* @property {number} byeCount
* @property {number[]} placement
* @property {KoSeed[]} seeding
* @property {Array<{round:number,label:string,virtualMatches:KoVirtualMatch[]}>} rounds
*/
function sanitizeMatchAverage(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number.parseFloat(String(value));
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 200) {
return null;
}
return Math.round(parsed * 100) / 100;
}
function sanitizeMatchHighFinish(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = clampInt(value, null, 1, 170);
return Number.isFinite(parsed) ? parsed : null;
}
function normalizePlayerStats(rawStats) {
return {
average: sanitizeMatchAverage(rawStats?.average),
oneEighties: clampInt(rawStats?.oneEighties, 0, 0, 99),
highFinish: sanitizeMatchHighFinish(rawStats?.highFinish),
};
}
function normalizeMatchStats(rawStats) {
return {
p1: normalizePlayerStats(rawStats?.p1),
p2: normalizePlayerStats(rawStats?.p2),
};
}
function createKoVirtualCompetitorRef(node) {
if (!node) {
return null;
}
if (node.kind === "participant") {
return {
type: "participant",
participantId: node.participantId,
seed: node.seed,
};
}
if (node.kind === "winner") {
return {
type: "winner",
matchId: node.sourceMatchId,
};
}
if (node.kind === "loser") {
return {
type: "loser",
matchId: node.sourceMatchId,
};
}
return null;
}
function createKoVirtualMatch({
id,
round,
number,
structuralBye = false,
competitors = null,
matchRole = "main",
advancesWinnerTo = null,
advancesLoserTo = null,
placementRank = null,
}) {
const normalizedRole = matchRole === "third_place" ? "third_place" : "main";
const normalizedPlacementRank = Number.isFinite(Number(placementRank))
? clampInt(placementRank, null, 1, 128)
: null;
return {
id: normalizeText(id || ""),
round: clampInt(round, 1, 1, 64),
number: clampInt(number, 1, 1, 256),
structuralBye: Boolean(structuralBye),
matchRole: normalizedRole,
advancesWinnerTo: normalizeText(advancesWinnerTo || "") || null,
advancesLoserTo: normalizeText(advancesLoserTo || "") || null,
placementRank: Number.isFinite(normalizedPlacementRank) ? normalizedPlacementRank : null,
competitors: {
p1: competitors?.p1 || null,
p2: competitors?.p2 || null,
},
};
}
function createMatch({
id,
stage,
round,
number,
groupId = null,
player1Id = null,
player2Id = null,
status = STATUS_PENDING,
winnerId = null,
source = null,
legs = null,
stats = null,
meta = {},
}) {
const normalizedStatus = status === STATUS_COMPLETED ? STATUS_COMPLETED : STATUS_PENDING;
const normalizedWinnerId = normalizedStatus === STATUS_COMPLETED
? normalizeText(winnerId || "") || null
: null;
const normalizedSource = source === "auto" || source === "manual" ? source : null;
return {
id,
stage,
round,
number,
groupId,
player1Id,
player2Id,
status: normalizedStatus,
winnerId: normalizedWinnerId,
source: normalizedSource,
legs: {
p1: clampInt(legs?.p1, 0, 0, 99),
p2: clampInt(legs?.p2, 0, 0, 99),
},
// Domain structure for required PDC match stats.
stats: normalizeMatchStats(stats),
updatedAt: nowIso(),
meta: normalizeMatchMeta(meta),
};
}
function createRoundRobinPairings(participantIds) {
const ids = participantIds.slice();
if (ids.length % 2 === 1) {
ids.push(null);
}
const rounds = [];
const total = ids.length;
const roundsCount = total - 1;
let rotation = ids.slice();
for (let roundIndex = 0; roundIndex < roundsCount; roundIndex += 1) {
const roundPairs = [];
for (let i = 0; i < total / 2; i += 1) {
const left = rotation[i];
const right = rotation[total - 1 - i];
if (left && right) {
roundPairs.push([left, right]);
}
}
rounds.push(roundPairs);
const fixed = rotation[0];
const rest = rotation.slice(1);
rest.unshift(rest.pop());
rotation = [fixed].concat(rest);
}
return rounds;
}
function buildLeagueMatches(participantIds) {
const rounds = createRoundRobinPairings(participantIds);
const matches = [];
rounds.forEach((pairs, roundIndex) => {
pairs.forEach((pair, pairIndex) => {
matches.push(createMatch({
id: `league-r${roundIndex + 1}-m${pairIndex + 1}`,
stage: MATCH_STAGE_LEAGUE,
round: roundIndex + 1,
number: pairIndex + 1,
player1Id: pair[0],
player2Id: pair[1],
}));
});
});
return matches;
}
function calculateBracketSize(participantCount) {
const normalizedCount = clampInt(participantCount, 0, 0, TECHNICAL_PARTICIPANT_HARD_MAX);
if (normalizedCount <= 2) {
return 2;
}
return nextPowerOfTwo(normalizedCount);
}
function buildSeedPlacement(size) {
if (!Number.isFinite(size) || size < 2 || size % 2 !== 0) {
return [];
}
let placement = [1];
while (placement.length < size) {
const mirrorBase = (placement.length * 2) + 1;
const next = [];
placement.forEach((seedNumber) => {
next.push(seedNumber, mirrorBase - seedNumber);
});
placement = next;
}
return placement;
}
function buildDeterministicSeedHash(value) {
const token = normalizeLookup(value || "");
let hash = 5381;
for (let i = 0; i < token.length; i += 1) {
hash = ((hash << 5) + hash) + token.charCodeAt(i);
hash >>>= 0;
}
return hash >>> 0;
}
function normalizeSeedParticipants(players) {
const source = Array.isArray(players) ? players : [];
const seen = new Set();
const list = [];
source.forEach((entry, index) => {
const participantId = normalizeText(entry?.id || entry || "");
if (!participantId || seen.has(participantId)) {
return;
}
seen.add(participantId);
const explicitSeed = Number.parseInt(String(entry?.seed ?? ""), 10);
list.push({
participantId,
participantName: normalizeText(entry?.name || participantId),
originalIndex: index,
explicitSeed: Number.isFinite(explicitSeed) && explicitSeed > 0 ? explicitSeed : null,
});
});
return list;
}
function generateSeeds(players, drawMode = KO_DRAW_MODE_SEEDED) {
const participants = normalizeSeedParticipants(players);
const mode = normalizeKoDrawMode(drawMode, KO_DRAW_MODE_SEEDED);
const ordered = participants.slice();
if (mode === KO_DRAW_MODE_OPEN_DRAW) {
ordered.sort((left, right) => {
const leftHash = buildDeterministicSeedHash(`${left.participantName}|${left.participantId}|${left.originalIndex}`);
const rightHash = buildDeterministicSeedHash(`${right.participantName}|${right.participantId}|${right.originalIndex}`);
if (leftHash !== rightHash) {
return leftHash - rightHash;
}
return left.originalIndex - right.originalIndex;
});
} else {
// Extension point: replace this comparator when external ranking-based seeding is added.
ordered.sort((left, right) => {
const leftSeed = Number.isFinite(left.explicitSeed) ? left.explicitSeed : Number.MAX_SAFE_INTEGER;
const rightSeed = Number.isFinite(right.explicitSeed) ? right.explicitSeed : Number.MAX_SAFE_INTEGER;
if (leftSeed !== rightSeed) {
return leftSeed - rightSeed;
}
return left.originalIndex - right.originalIndex;
});
}
return ordered.map((entry, index) => ({
participantId: entry.participantId,
participantName: entry.participantName,
seed: index + 1,
}));
}
function assignByes(players, bracketSize) {
const seeds = Array.isArray(players) ? players.slice() : [];
const size = calculateBracketSize(bracketSize || seeds.length);
const byeCount = Math.max(0, size - seeds.length);
const seededWithByes = seeds.map((seedEntry, index) => ({
...seedEntry,
hasBye: index < byeCount,
entryRound: index < byeCount ? 2 : 1,
}));
return {
bracketSize: size,
byeCount,
seeds: seededWithByes,
};
}
function buildBracketStructure(players, seeds, options = {}) {
const normalizedParticipants = normalizeSeedParticipants(players);
const enableThirdPlaceMatch = Boolean(options?.enableThirdPlaceMatch);
const seeded = Array.isArray(seeds) && seeds.length
? seeds.slice()
: generateSeeds(normalizedParticipants, KO_DRAW_MODE_SEEDED);
const assignedByes = assignByes(seeded, normalizedParticipants.length);
const placement = buildSeedPlacement(assignedByes.bracketSize);
const seedByNumber = new Map(assignedByes.seeds.map((entry) => [entry.seed, entry]));
const slotByParticipantId = new Map();
const leafNodes = placement.map((seedNumber, slotIndex) => {
const seedEntry = seedByNumber.get(seedNumber) || null;
if (!seedEntry) {
return null;
}
slotByParticipantId.set(seedEntry.participantId, slotIndex + 1);
return {
kind: "participant",
participantId: seedEntry.participantId,
seed: seedEntry.seed,
};
});
const seeding = assignedByes.seeds.map((seedEntry) => ({
...seedEntry,
slot: slotByParticipantId.get(seedEntry.participantId) || null,
}));
const rounds = [];
let currentNodes = leafNodes;
const totalRounds = Math.log2(assignedByes.bracketSize);
for (let round = 1; round <= totalRounds; round += 1) {
const matchesInRound = currentNodes.length / 2;
const virtualMatches = [];
const nextNodes = [];
for (let number = 1; number <= matchesInRound; number += 1) {
const idx = (number - 1) * 2;
const leftNode = currentNodes[idx] || null;
const rightNode = currentNodes[idx + 1] || null;
const id = `ko-r${round}-m${number}`;
const structuralBye = Boolean((leftNode && !rightNode) || (!leftNode && rightNode));
virtualMatches.push(createKoVirtualMatch({
id,
round,
number,
structuralBye,
matchRole: "main",
advancesWinnerTo: round < totalRounds
? `ko-r${round + 1}-m${Math.ceil(number / 2)}`
: null,
advancesLoserTo: null,
placementRank: round === totalRounds ? 1 : null,
competitors: {
p1: createKoVirtualCompetitorRef(leftNode),
p2: createKoVirtualCompetitorRef(rightNode),
},
}));
if (!leftNode && !rightNode) {
nextNodes.push(null);
} else if (structuralBye) {
nextNodes.push(leftNode || rightNode);
} else {
nextNodes.push({
kind: "winner",
sourceMatchId: id,
});
}
}
rounds.push({
round,
label: getKoRoundLabel(round, totalRounds),
virtualMatches,
});
currentNodes = nextNodes;
}
if (enableThirdPlaceMatch && totalRounds >= 2) {
const semifinalRound = totalRounds - 1;
const semifinalRoundDef = rounds.find((entry) => entry.round === semifinalRound);
const finalRoundDef = rounds.find((entry) => entry.round === totalRounds);
const semifinalMatches = (Array.isArray(semifinalRoundDef?.virtualMatches) ? semifinalRoundDef.virtualMatches : [])
.filter((entry) => entry?.matchRole !== "third_place")
.sort((left, right) => left.number - right.number);
const isValidThirdPlacePath = semifinalMatches.length === 2
&& semifinalMatches.every((entry) => (
!entry.structuralBye
&& Boolean(entry?.competitors?.p1)
&& Boolean(entry?.competitors?.p2)
));
if (isValidThirdPlacePath && finalRoundDef) {
const thirdPlaceMatchId = `ko-r${totalRounds}-m2`;
semifinalMatches.forEach((entry) => {
entry.advancesLoserTo = thirdPlaceMatchId;
});
const thirdPlaceMatch = createKoVirtualMatch({
id: thirdPlaceMatchId,
round: totalRounds,
number: 2,
structuralBye: false,
matchRole: "third_place",
advancesWinnerTo: null,
advancesLoserTo: null,
placementRank: 3,
competitors: {
p1: createKoVirtualCompetitorRef({ kind: "loser", sourceMatchId: semifinalMatches[0].id }),
p2: createKoVirtualCompetitorRef({ kind: "loser", sourceMatchId: semifinalMatches[1].id }),
},
});
finalRoundDef.virtualMatches = finalRoundDef.virtualMatches
.filter((entry) => entry.id !== thirdPlaceMatch.id)
.concat(thirdPlaceMatch)
.sort((left, right) => left.number - right.number);
}
}
return {
bracketSize: assignedByes.bracketSize,
byeCount: assignedByes.byeCount,
enableThirdPlaceMatch,
placement,
seeding,
rounds,
};
}
function buildKoMatchMetaFromVirtualMatch(virtualMatch) {
return {
bracket: {
p1Source: virtualMatch?.competitors?.p1 || null,
p2Source: virtualMatch?.competitors?.p2 || null,
matchRole: virtualMatch?.matchRole === "third_place" ? "third_place" : "main",
advancesWinnerTo: normalizeText(virtualMatch?.advancesWinnerTo || "") || null,
advancesLoserTo: normalizeText(virtualMatch?.advancesLoserTo || "") || null,
placementRank: Number.isFinite(Number(virtualMatch?.placementRank))
? clampInt(virtualMatch?.placementRank, null, 1, 128)
: null,
},
};
}
function resolveInitialVirtualParticipantId(competitorRef) {
if (!competitorRef || competitorRef.type !== "participant") {
return null;
}
return normalizeText(competitorRef.participantId || "") || null;
}
function buildKoMatchesFromStructure(bracketStructure) {
const rounds = Array.isArray(bracketStructure?.rounds) ? bracketStructure.rounds : [];
const matches = [];
rounds.forEach((roundDef) => {
roundDef.virtualMatches.forEach((virtualMatch) => {
const p1 = resolveInitialVirtualParticipantId(virtualMatch?.competitors?.p1);
const p2 = resolveInitialVirtualParticipantId(virtualMatch?.competitors?.p2);
const structuralBye = Boolean(virtualMatch?.structuralBye);
const advancedParticipantId = structuralBye ? (p1 || p2 || null) : null;
const isBye = structuralBye && Boolean(advancedParticipantId);
const baseMeta = buildKoMatchMetaFromVirtualMatch(virtualMatch);
const meta = isBye
? { ...baseMeta, resultKind: "bye" }
: baseMeta;
matches.push(createMatch({
id: virtualMatch.id,
stage: MATCH_STAGE_KO,
round: virtualMatch.round,
number: virtualMatch.number,
player1Id: p1,
player2Id: p2,
status: isBye ? STATUS_COMPLETED : STATUS_PENDING,
winnerId: isBye ? advancedParticipantId : null,
legs: isBye ? { p1: 0, p2: 0 } : { p1: 0, p2: 0 },
meta,
}));
});
});
return matches;
}
function buildKoMatchesV2(participantIds, drawMode = KO_DRAW_MODE_SEEDED) {
const participants = (Array.isArray(participantIds) ? participantIds : [])
.map((entry) => ({
id: normalizeText(entry?.id || entry || ""),
name: normalizeText(entry?.name || entry?.id || entry || ""),
seed: entry?.seed,
}))
.filter((entry) => entry.id);
const seeds = generateSeeds(participants, drawMode);
const structure = buildBracketStructure(participants, seeds);
return buildKoMatchesFromStructure(structure);
}
function buildGroups(participantIds) {
const groupA = [];
const groupB = [];
participantIds.forEach((participantId, index) => {
if (index % 2 === 0) {
groupA.push(participantId);
} else {
groupB.push(participantId);
}
});
return [
{ id: "A", name: "Gruppe A", participantIds: groupA },
{ id: "B", name: "Gruppe B", participantIds: groupB },
];
}
function buildGroupMatches(groups) {
const matches = [];
groups.forEach((group) => {
const rounds = createRoundRobinPairings(group.participantIds);
rounds.forEach((pairs, roundIndex) => {
pairs.forEach((pair, pairIndex) => {
matches.push(createMatch({
id: `group-${group.id}-r${roundIndex + 1}-m${pairIndex + 1}`,
stage: MATCH_STAGE_GROUP,
groupId: group.id,
round: roundIndex + 1,
number: pairIndex + 1,
player1Id: pair[0],
player2Id: pair[1],
}));
});
});
});
return matches;
}
function buildGroupsKoMatches() {
return [
createMatch({
id: "ko-r1-m1",
stage: MATCH_STAGE_KO,
round: 1,
number: 1,
meta: {
from1: { type: "groupRank", groupId: "A", rank: 1 },
from2: { type: "groupRank", groupId: "B", rank: 2 },
},
}),
createMatch({
id: "ko-r1-m2",
stage: MATCH_STAGE_KO,
round: 1,
number: 2,
meta: {
from1: { type: "groupRank", groupId: "B", rank: 1 },
from2: { type: "groupRank", groupId: "A", rank: 2 },
},
}),
createMatch({
id: "ko-r2-m1",
stage: MATCH_STAGE_KO,
round: 2,
number: 1,
}),
];
}
function validateCreateConfig(config) {
const errors = [];
if (!normalizeText(config.name)) {
errors.push("Bitte einen Turniernamen eingeben.");
}
if (!["ko", "league", "groups_ko"].includes(config.mode)) {
errors.push("Ungültiger Modus.");
}
const participantCountError = getParticipantCountError(config.mode, config.participants.length);
if (participantCountError) {
errors.push(participantCountError);
}
return errors;
}
function createTournament(config) {
const modeLimits = getModeParticipantLimits(config.mode);
const participants = config.participants.slice(0, modeLimits.max);
const participantIds = participants.map((participant) => participant.id);
const koDrawMode = config.mode === "ko" && config.randomizeKoRound1
? KO_DRAW_MODE_OPEN_DRAW
: KO_DRAW_MODE_SEEDED;
const koDrawLocked = config.mode === "ko"
? config.koDrawLocked !== false
: false;
const enableThirdPlaceMatch = config.mode === "ko"
? config.enableThirdPlaceMatch === true
: false;
const x01 = normalizeTournamentX01Settings({
presetId: config.x01Preset,
baseScore: config.startScore,
inMode: config.x01InMode,
outMode: config.x01OutMode,
bullMode: config.x01BullMode,
maxRounds: config.x01MaxRounds,
bullOffMode: config.x01BullOffMode,
lobbyVisibility: config.lobbyVisibility,
}, config.startScore);
let groups = [];
let matches = [];
let koMeta = null;
if (config.mode === "league") {
matches = buildLeagueMatches(participantIds);
} else if (config.mode === "groups_ko") {
groups = buildGroups(participantIds);
matches = buildGroupMatches(groups).concat(buildGroupsKoMatches());
} else {
const koSeeds = generateSeeds(participants, koDrawMode);
const koStructure = buildBracketStructure(participants, koSeeds, { enableThirdPlaceMatch });
matches = buildKoMatchesFromStructure(koStructure);
koMeta = {
drawMode: koDrawMode,
drawLocked: koDrawLocked,
enableThirdPlaceMatch: koStructure.enableThirdPlaceMatch,
engineVersion: KO_ENGINE_VERSION,
bracketSize: koStructure.bracketSize,
placement: koStructure.placement,
seeding: koStructure.seeding,
rounds: koStructure.rounds,
};
}
const tournament = {
id: uuid("tournament"),
name: normalizeText(config.name),
mode: config.mode,
ko: koMeta,
bestOfLegs: sanitizeBestOf(config.bestOfLegs),
startScore: x01.baseScore,
x01,
rules: normalizeTournamentRules({ tieBreakProfile: TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE }),
duration: {
boardCount: sanitizeTournamentBoardCount(
config?.boardCount,
TOURNAMENT_DURATION_DEFAULT_BOARD_COUNT,
),
},
participants,
groups,
matches,
results: [],
createdAt: nowIso(),
updatedAt: nowIso(),
};
return tournament;
}
function getMatchesByStage(tournament, stage) {
return tournament.matches
.filter((match) => match.stage === stage)
.sort((left, right) => left.round - right.round || left.number - right.number);
}
function findMatch(tournament, matchId) {
return tournament.matches.find((match) => match.id === matchId) || null;
}
// Domain layer: deterministic tournament duration estimation with dependency-aware board scheduling.
const TOURNAMENT_DURATION_BASE_LEG_MINUTES = 3.75;
const TOURNAMENT_DURATION_RESULT_ENTRY_MINUTES = 0.80;
const TOURNAMENT_DURATION_LOW_FACTOR = 0.90;
const TOURNAMENT_DURATION_HIGH_BASE_PADDING = 0.12;
const TOURNAMENT_DURATION_SCORE_FACTORS = Object.freeze({
121: 0.50,
170: 0.58,
301: 0.74,
501: 1.00,
701: 1.24,
901: 1.42,
});
const TOURNAMENT_DURATION_IN_FACTORS = Object.freeze({
Straight: 1.00,
Double: 1.06,
Master: 1.10,
});
const TOURNAMENT_DURATION_OUT_FACTORS = Object.freeze({
Straight: 0.93,
Double: 1.00,
Master: 1.05,
});
const TOURNAMENT_DURATION_BULL_FACTORS = Object.freeze({
"25/50": 1.00,
"50/50": 0.98,
});
const TOURNAMENT_DURATION_BULL_OFF_OVERHEAD = Object.freeze({
Off: 0.00,
Normal: 0.40,
Official: 0.65,
});
const TOURNAMENT_DURATION_MAX_ROUNDS_HIGH_PADDING = Object.freeze({
15: 0.00,
20: 0.02,
50: 0.06,
80: 0.09,
});
const TOURNAMENT_TIME_PROFILE_META = Object.freeze({
[TOURNAMENT_TIME_PROFILE_FAST]: Object.freeze({
id: TOURNAMENT_TIME_PROFILE_FAST,
label: "Schnell",
description: "F\u00fcr z\u00fcgige Felder mit wenig Verz\u00f6gerung zwischen den Matches.",
legPaceMultiplier: 0.88,
matchTransitionMinutes: 0.55,
phaseTransitionMultiplier: 0.90,
highPaddingExtra: 0.00,
}),
[TOURNAMENT_TIME_PROFILE_NORMAL]: Object.freeze({
id: TOURNAMENT_TIME_PROFILE_NORMAL,
label: "Normal",
description: "Ausgewogener Standard f\u00fcr lokale Turniere.",
legPaceMultiplier: 1.00,
matchTransitionMinutes: 0.80,
phaseTransitionMultiplier: 1.00,
highPaddingExtra: 0.00,
}),
[TOURNAMENT_TIME_PROFILE_SLOW]: Object.freeze({
id: TOURNAMENT_TIME_PROFILE_SLOW,
label: "Langsam",
description: "F\u00fcr gemischte Felder oder langsamere Board-Wechsel.",
legPaceMultiplier: 1.15,
matchTransitionMinutes: 1.15,
phaseTransitionMultiplier: 1.15,
highPaddingExtra: 0.02,
}),
});
function getTournamentTimeProfileMeta(profileId = TOURNAMENT_TIME_PROFILE_NORMAL) {
const normalized = sanitizeTournamentTimeProfile(profileId, TOURNAMENT_TIME_PROFILE_NORMAL);
return TOURNAMENT_TIME_PROFILE_META[normalized] || TOURNAMENT_TIME_PROFILE_META[TOURNAMENT_TIME_PROFILE_NORMAL];
}
function getTournamentDurationCombination(n, k) {
if (!Number.isFinite(n) || !Number.isFinite(k) || k < 0 || k > n) {
return 0;
}
let normalizedK = k;
if (normalizedK > n - normalizedK) {
normalizedK = n - normalizedK;
}
let result = 1;
for (let index = 1; index <= normalizedK; index += 1) {
result = (result * (n - normalizedK + index)) / index;
}
return result;
}
function getExpectedLegsForBestOf(bestOfLegs) {
const bestOf = sanitizeBestOf(bestOfLegs);
const legsToWin = getLegsToWin(bestOf);
let expectedLegs = 0;
for (let totalLegs = legsToWin; totalLegs < legsToWin * 2; totalLegs += 1) {
const probability = 2
* getTournamentDurationCombination(totalLegs - 1, legsToWin - 1)
* Math.pow(0.5, totalLegs);
expectedLegs += totalLegs * probability;
}
return expectedLegs;
}
function getTournamentDurationMatchCount(mode, participantCount) {
const count = clampInt(participantCount, 0, 0, TECHNICAL_PARTICIPANT_HARD_MAX);
if (mode === "league") {
return (count * (count - 1)) / 2;
}
if (mode === "groups_ko") {
const groupA = Math.ceil(count / 2);
const groupB = Math.floor(count / 2);
return ((groupA * (groupA - 1)) / 2) + ((groupB * (groupB - 1)) / 2) + 3;
}
return Math.max(0, count - 1);
}
function getTournamentDurationPhaseOverheadMinutes(mode, participantCount) {
const count = clampInt(participantCount, 0, 0, TECHNICAL_PARTICIPANT_HARD_MAX);
if (mode === "groups_ko") {
return 4;
}
if (mode !== "ko" || count < 2) {
return 0;
}
const bracketSize = calculateBracketSize(count);
const rounds = Math.log2(bracketSize);
return Math.max(0, rounds - 1) * 1.5;
}
function getTournamentDurationDifficultyPadding(x01Settings) {
let padding = 0;
if (x01Settings.baseScore === 701) {
padding += 0.03;
} else if (x01Settings.baseScore === 901) {
padding += 0.05;
}
if (x01Settings.inMode === "Double") {
padding += 0.01;
} else if (x01Settings.inMode === "Master") {
padding += 0.02;
}
if (x01Settings.outMode === "Master") {
padding += 0.01;
}
if (x01Settings.bullOffMode === "Official") {
padding += 0.01;
}
return padding;
}
function normalizeTournamentDurationParticipants(rawParticipants) {
const source = Array.isArray(rawParticipants) ? rawParticipants : [];
return source
.map((entry, index) => {
const id = normalizeText(entry?.id || entry?.name || entry || `p-${index + 1}`);
return id || null;
})
.filter(Boolean);
}
function normalizeTournamentDurationTaskList(rawTasks) {
const source = Array.isArray(rawTasks) ? rawTasks : [];
const seen = new Set();
const normalized = [];
source.forEach((task, index) => {
const taskId = normalizeText(task?.id || `duration-task-${index + 1}`);
if (!taskId || seen.has(taskId)) {
return;
}
seen.add(taskId);
const participants = (Array.isArray(task?.participants) ? task.participants : [])
.map((entry) => normalizeText(entry || ""))
.filter(Boolean);
const dependsOn = (Array.isArray(task?.dependsOn) ? task.dependsOn : [])
.map((entry) => normalizeText(entry || ""))
.filter(Boolean);
normalized.push({
id: taskId,
participants,
dependsOn,
});
});
const taskIds = new Set(normalized.map((task) => task.id));
return normalized.map((task) => ({
...task,
dependsOn: task.dependsOn.filter((depId) => depId !== task.id && taskIds.has(depId)),
}));
}
function buildTournamentDurationDependents(tasks) {
const dependentsById = new Map();
tasks.forEach((task) => {
dependentsById.set(task.id, []);
});
tasks.forEach((task) => {
task.dependsOn.forEach((depId) => {
if (!dependentsById.has(depId)) {
return;
}
dependentsById.get(depId).push(task.id);
});
});
return dependentsById;
}
function getTournamentDurationTaskDepth(taskId, dependentsById, memo, visiting) {
if (memo.has(taskId)) {
return memo.get(taskId);
}
if (visiting.has(taskId)) {
return 1;
}
visiting.add(taskId);
const dependents = dependentsById.get(taskId) || [];
let depth = 1;
dependents.forEach((dependentId) => {
depth = Math.max(
depth,
1 + getTournamentDurationTaskDepth(dependentId, dependentsById, memo, visiting),
);
});
visiting.delete(taskId);
memo.set(taskId, depth);
return depth;
}
function estimateTournamentDurationSchedule(rawTasks, boardCount) {
const safeBoardCount = sanitizeTournamentBoardCount(
boardCount,
TOURNAMENT_DURATION_DEFAULT_BOARD_COUNT,
);
const tasks = normalizeTournamentDurationTaskList(rawTasks);
if (!tasks.length) {
return {
waves: 0,
peakParallelMatches: 0,
averageParallelMatches: 0,
boardUtilization: 0,
};
}
const taskById = new Map(tasks.map((task) => [task.id, task]));
const dependentsById = buildTournamentDurationDependents(tasks);
const remainingDependenciesById = new Map();
tasks.forEach((task) => {
const dependencyCount = task.dependsOn.filter((depId) => taskById.has(depId)).length;
remainingDependenciesById.set(task.id, dependencyCount);
});
const criticalDepthById = new Map();
tasks.forEach((task) => {
getTournamentDurationTaskDepth(task.id, dependentsById, criticalDepthById, new Set());
});
const unscheduled = new Set(tasks.map((task) => task.id));
let waves = 0;
let peakParallelMatches = 0;
let scheduledMatches = 0;
while (unscheduled.size > 0) {
const ready = tasks
.filter((task) => unscheduled.has(task.id) && (remainingDependenciesById.get(task.id) || 0) <= 0)
.sort((left, right) => {
const leftDepth = criticalDepthById.get(left.id) || 1;
const rightDepth = criticalDepthById.get(right.id) || 1;
if (leftDepth !== rightDepth) {
return rightDepth - leftDepth;
}
const leftDependentCount = (dependentsById.get(left.id) || []).length;
const rightDependentCount = (dependentsById.get(right.id) || []).length;
if (leftDependentCount !== rightDependentCount) {
return rightDependentCount - leftDependentCount;
}
if (left.participants.length !== right.participants.length) {
return right.participants.length - left.participants.length;
}
return left.id.localeCompare(right.id);
});
if (!ready.length) {
const remaining = unscheduled.size;
const fallbackWaves = Math.ceil(remaining / safeBoardCount);
waves += fallbackWaves;
peakParallelMatches = Math.max(peakParallelMatches, Math.min(safeBoardCount, remaining));
scheduledMatches += remaining;
break;
}
const usedParticipants = new Set();
const selected = [];
for (let index = 0; index < ready.length; index += 1) {
if (selected.length >= safeBoardCount) {
break;
}
const task = ready[index];
const hasConflict = task.participants.some((participantId) => usedParticipants.has(participantId));
if (hasConflict) {
continue;
}
selected.push(task);
task.participants.forEach((participantId) => usedParticipants.add(participantId));
}
if (!selected.length) {
selected.push(ready[0]);
}
waves += 1;
peakParallelMatches = Math.max(peakParallelMatches, selected.length);
selected.forEach((task) => {
if (!unscheduled.has(task.id)) {
return;
}
unscheduled.delete(task.id);
scheduledMatches += 1;
const dependents = dependentsById.get(task.id) || [];
dependents.forEach((dependentId) => {
const currentCount = remainingDependenciesById.get(dependentId) || 0;
remainingDependenciesById.set(dependentId, Math.max(0, currentCount - 1));
});
});
}
const averageParallelMatches = waves > 0 ? scheduledMatches / waves : 0;
const boardUtilization = waves > 0
? scheduledMatches / (waves * safeBoardCount)
: 0;
return {
waves,
peakParallelMatches,
averageParallelMatches,
boardUtilization,
};
}
function buildKoTournamentDurationTasks(participantCount, enableThirdPlaceMatch = false) {
const count = clampInt(participantCount, 0, 0, TECHNICAL_PARTICIPANT_HARD_MAX);
if (count < 2) {
return [];
}
const participants = Array.from({ length: count }, (_, index) => ({
id: `ko-p-${index + 1}`,
name: `P${index + 1}`,
}));
const structure = buildBracketStructure(
participants,
generateSeeds(participants, KO_DRAW_MODE_SEEDED),
{ enableThirdPlaceMatch: Boolean(enableThirdPlaceMatch) },
);
const playableMatchIds = new Set();
structure.rounds.forEach((roundDef) => {
roundDef.virtualMatches.forEach((virtualMatch) => {
if (virtualMatch?.structuralBye) {
return;
}
if (!virtualMatch?.competitors?.p1 || !virtualMatch?.competitors?.p2) {
return;
}
playableMatchIds.add(virtualMatch.id);
});
});
const tasks = [];
structure.rounds.forEach((roundDef) => {
roundDef.virtualMatches.forEach((virtualMatch) => {
if (!playableMatchIds.has(virtualMatch.id)) {
return;
}
const participantsInMatch = [];
const dependencies = [];
[virtualMatch.competitors?.p1, virtualMatch.competitors?.p2].forEach((competitorRef) => {
if (competitorRef?.type === "participant") {
const participantId = normalizeText(competitorRef.participantId || "");
if (participantId) {
participantsInMatch.push(`p:${participantId}`);
}
return;
}
if (competitorRef?.type !== "winner") {
return;
}
const sourceMatchId = normalizeText(competitorRef.matchId || "");
if (!sourceMatchId) {
return;
}
participantsInMatch.push(`w:${sourceMatchId}`);
if (playableMatchIds.has(sourceMatchId)) {
dependencies.push(sourceMatchId);
}
});
tasks.push({
id: virtualMatch.id,
participants: participantsInMatch,
dependsOn: dependencies,
});
});
});
return tasks;
}
function buildLeagueTournamentDurationTasks(rawParticipants) {
const participantIds = normalizeTournamentDurationParticipants(rawParticipants);
const rounds = createRoundRobinPairings(participantIds);
const tasks = [];
rounds.forEach((pairs, roundIndex) => {
pairs.forEach((pair, pairIndex) => {
tasks.push({
id: `league-r${roundIndex + 1}-m${pairIndex + 1}`,
participants: [`p:${pair[0]}`, `p:${pair[1]}`],
dependsOn: [],
});
});
});
return tasks;
}
function buildGroupsKoTournamentDurationTasks(rawParticipants) {
const participantIds = normalizeTournamentDurationParticipants(rawParticipants);
const groups = buildGroups(participantIds);
const tasks = [];
const groupStageTaskIds = [];
groups.forEach((group) => {
const rounds = createRoundRobinPairings(group.participantIds);
rounds.forEach((pairs, roundIndex) => {
pairs.forEach((pair, pairIndex) => {
const taskId = `group-${group.id}-r${roundIndex + 1}-m${pairIndex + 1}`;
tasks.push({
id: taskId,
participants: [`p:${pair[0]}`, `p:${pair[1]}`],
dependsOn: [],
});
groupStageTaskIds.push(taskId);
});
});
});
const semifinalAId = "ko-r1-m1";
const semifinalBId = "ko-r1-m2";
const finalId = "ko-r2-m1";
tasks.push({
id: semifinalAId,
participants: ["slot:A1", "slot:B2"],
dependsOn: groupStageTaskIds,
});
tasks.push({
id: semifinalBId,
participants: ["slot:B1", "slot:A2"],
dependsOn: groupStageTaskIds,
});
tasks.push({
id: finalId,
participants: [`w:${semifinalAId}`, `w:${semifinalBId}`],
dependsOn: [semifinalAId, semifinalBId],
});
return tasks;
}
function buildTournamentDurationTasks(mode, participants, participantCount, options = {}) {
if (mode === "league") {
return buildLeagueTournamentDurationTasks(participants);
}
if (mode === "groups_ko") {
return buildGroupsKoTournamentDurationTasks(participants);
}
return buildKoTournamentDurationTasks(participantCount, options?.enableThirdPlaceMatch === true);
}
function buildCompletedTournamentDurationTaskIdSet(tournament, tasks) {
const validTaskIds = new Set((Array.isArray(tasks) ? tasks : []).map((task) => task.id));
const completedIds = new Set();
(Array.isArray(tournament?.matches) ? tournament.matches : []).forEach((match) => {
if (match?.status !== STATUS_COMPLETED) {
return;
}
const taskId = normalizeText(match?.id || "");
if (taskId && validTaskIds.has(taskId)) {
completedIds.add(taskId);
}
});
return completedIds;
}
function estimateTournamentDurationProgressFromTournament(tournament, settings = null) {
const totalEstimate = estimateTournamentDurationFromTournament(tournament, settings);
const progress = {
ready: totalEstimate.ready,
reason: totalEstimate.reason,
totalEstimate,
completedMatches: 0,
remainingMatches: totalEstimate.matchCount,
progressRatio: 0,
remainingScheduleWaves: totalEstimate.scheduleWaves,
remainingLikelyMinutes: totalEstimate.likelyMinutes,
remainingLowMinutes: totalEstimate.lowMinutes,
remainingHighMinutes: totalEstimate.highMinutes,
modelElapsedMinutes: 0,
elapsedMinutes: 0,
paceMultiplier: 1,
projectedRemainingLikelyMinutes: totalEstimate.likelyMinutes,
projectedEndAtIso: "",
};
if (!totalEstimate.ready || !tournament) {
return progress;
}
const mode = normalizeText(tournament?.mode || "ko");
const participants = Array.isArray(tournament?.participants) ? tournament.participants : [];
const tasks = buildTournamentDurationTasks(mode, participants, participants.length, {
enableThirdPlaceMatch: tournament?.ko?.enableThirdPlaceMatch === true,
});
if (!tasks.length) {
return progress;
}
const completedTaskIds = buildCompletedTournamentDurationTaskIdSet(tournament, tasks);
const remainingTasks = tasks.filter((task) => !completedTaskIds.has(task.id));
const remainingSchedule = estimateTournamentDurationSchedule(remainingTasks, totalEstimate.boardCount);
const remainingWaves = remainingSchedule.waves;
const remainingLikelyMinutes = (remainingWaves * totalEstimate.matchMinutes)
+ (totalEstimate.phaseOverheadMinutes * (remainingTasks.length / tasks.length));
const remainingLowMinutes = remainingLikelyMinutes * TOURNAMENT_DURATION_LOW_FACTOR;
const likelyHighFactor = totalEstimate.likelyMinutes > 0
? totalEstimate.highMinutes / totalEstimate.likelyMinutes
: (1 + TOURNAMENT_DURATION_HIGH_BASE_PADDING);
const remainingHighMinutes = remainingLikelyMinutes * likelyHighFactor;
const completedMatches = tasks.length - remainingTasks.length;
const progressRatio = tasks.length > 0 ? (completedMatches / tasks.length) : 0;
const modelElapsedMinutes = Math.max(0, totalEstimate.likelyMinutes - remainingLikelyMinutes);
const elapsedMinutes = 0;
const paceMultiplier = 1;
const projectedRemainingLikelyMinutes = remainingLikelyMinutes;
const projectedEndAtIso = "";
progress.completedMatches = completedMatches;
progress.remainingMatches = remainingTasks.length;
progress.progressRatio = progressRatio;
progress.remainingScheduleWaves = remainingWaves;
progress.remainingLikelyMinutes = remainingLikelyMinutes;
progress.remainingLowMinutes = remainingLowMinutes;
progress.remainingHighMinutes = remainingHighMinutes;
progress.modelElapsedMinutes = modelElapsedMinutes;
progress.elapsedMinutes = elapsedMinutes;
progress.paceMultiplier = paceMultiplier;
progress.projectedRemainingLikelyMinutes = projectedRemainingLikelyMinutes;
progress.projectedEndAtIso = projectedEndAtIso;
return progress;
}
function estimateTournamentDuration(rawInput, settings = null) {
const modeRaw = normalizeText(rawInput?.mode || "ko");
const mode = ["ko", "league", "groups_ko"].includes(modeRaw) ? modeRaw : "ko";
const participants = (Array.isArray(rawInput?.participants) ? rawInput.participants : [])
.filter((entry) => normalizeText(entry?.id || entry?.name || entry || ""));
const participantCount = participants.length;
const participantLimits = getModeParticipantLimits(mode);
const profile = getTournamentTimeProfileMeta(
rawInput?.tournamentTimeProfile ?? settings?.tournamentTimeProfile,
);
const x01Settings = normalizeTournamentX01Settings({
presetId: rawInput?.x01Preset,
baseScore: rawInput?.startScore,
inMode: rawInput?.x01InMode,
outMode: rawInput?.x01OutMode,
bullMode: rawInput?.x01BullMode,
maxRounds: rawInput?.x01MaxRounds,
bullOffMode: rawInput?.x01BullOffMode,
lobbyVisibility: rawInput?.lobbyVisibility,
}, rawInput?.startScore);
const bestOfLegs = sanitizeBestOf(rawInput?.bestOfLegs);
const boardCount = sanitizeTournamentBoardCount(
rawInput?.boardCount,
TOURNAMENT_DURATION_DEFAULT_BOARD_COUNT,
);
const estimate = {
ready: false,
reason: "",
mode,
participantCount,
participantLimits,
profile,
bestOfLegs,
legsToWin: getLegsToWin(bestOfLegs),
x01: x01Settings,
expectedLegs: 0,
legMinutes: 0,
resultEntryMinutes: TOURNAMENT_DURATION_RESULT_ENTRY_MINUTES,
matchTransitionMinutes: 0,
bullOffOverheadMinutes: 0,
matchOverheadMinutes: 0,
matchMinutes: 0,
matchCount: 0,
boardCount,
scheduleWaves: 0,
averageParallelMatches: 0,
peakParallelMatches: 0,
boardUtilization: 0,
phaseOverheadMinutes: 0,
likelyMinutes: 0,
lowMinutes: 0,
highMinutes: 0,
singleBoard: boardCount === 1,
};
if (participantCount < participantLimits.min || participantCount > participantLimits.max) {
estimate.reason = getParticipantCountError(mode, participantCount);
return estimate;
}
const expectedLegs = getExpectedLegsForBestOf(bestOfLegs);
const bullModeFactor = x01Settings.bullOffMode === "Off"
? 1
: (TOURNAMENT_DURATION_BULL_FACTORS[x01Settings.bullMode] || 1);
const legMinutes = TOURNAMENT_DURATION_BASE_LEG_MINUTES
* profile.legPaceMultiplier
* (TOURNAMENT_DURATION_SCORE_FACTORS[x01Settings.baseScore] || 1)
* (TOURNAMENT_DURATION_IN_FACTORS[x01Settings.inMode] || 1)
* (TOURNAMENT_DURATION_OUT_FACTORS[x01Settings.outMode] || 1)
* bullModeFactor;
const bullOffOverheadMinutes = TOURNAMENT_DURATION_BULL_OFF_OVERHEAD[x01Settings.bullOffMode] || 0;
const matchOverheadMinutes = TOURNAMENT_DURATION_RESULT_ENTRY_MINUTES
+ profile.matchTransitionMinutes
+ bullOffOverheadMinutes;
const matchMinutes = (expectedLegs * legMinutes) + matchOverheadMinutes;
const durationTasks = buildTournamentDurationTasks(mode, participants, participantCount, {
enableThirdPlaceMatch: rawInput?.enableThirdPlaceMatch === true,
});
const fallbackMatchCount = getTournamentDurationMatchCount(mode, participantCount);
const matchCount = durationTasks.length || fallbackMatchCount;
const schedule = estimateTournamentDurationSchedule(durationTasks, boardCount);
const scheduleWaves = schedule.waves > 0
? schedule.waves
: (matchCount > 0 ? Math.ceil(matchCount / boardCount) : 0);
const phaseOverheadMinutes = getTournamentDurationPhaseOverheadMinutes(mode, participantCount)
* profile.phaseTransitionMultiplier;
const likelyMinutes = (scheduleWaves * matchMinutes) + phaseOverheadMinutes;
const highPadding = TOURNAMENT_DURATION_HIGH_BASE_PADDING
+ (TOURNAMENT_DURATION_MAX_ROUNDS_HIGH_PADDING[x01Settings.maxRounds] || 0)
+ getTournamentDurationDifficultyPadding(x01Settings)
+ profile.highPaddingExtra;
estimate.ready = true;
estimate.expectedLegs = expectedLegs;
estimate.legMinutes = legMinutes;
estimate.matchTransitionMinutes = profile.matchTransitionMinutes;
estimate.bullOffOverheadMinutes = bullOffOverheadMinutes;
estimate.matchOverheadMinutes = matchOverheadMinutes;
estimate.matchMinutes = matchMinutes;
estimate.matchCount = matchCount;
estimate.scheduleWaves = scheduleWaves;
estimate.averageParallelMatches = scheduleWaves > 0
? (matchCount / scheduleWaves)
: 0;
estimate.peakParallelMatches = schedule.peakParallelMatches > 0
? schedule.peakParallelMatches
: (matchCount > 0 ? Math.min(boardCount, matchCount) : 0);
estimate.boardUtilization = scheduleWaves > 0
? (matchCount / (scheduleWaves * boardCount))
: 0;
estimate.phaseOverheadMinutes = phaseOverheadMinutes;
estimate.likelyMinutes = likelyMinutes;
estimate.lowMinutes = likelyMinutes * TOURNAMENT_DURATION_LOW_FACTOR;
estimate.highMinutes = likelyMinutes * (1 + highPadding);
return estimate;
}
function estimateTournamentDurationFromDraft(rawDraft, settings = null) {
const draft = normalizeCreateDraft(rawDraft, settings);
const participants = parseParticipantLines(draft.participantsText);
return estimateTournamentDuration({
mode: draft.mode,
bestOfLegs: draft.bestOfLegs,
startScore: draft.startScore,
x01Preset: draft.x01Preset,
x01InMode: draft.x01InMode,
x01OutMode: draft.x01OutMode,
x01BullMode: draft.x01BullMode,
x01MaxRounds: draft.x01MaxRounds,
x01BullOffMode: draft.x01BullOffMode,
lobbyVisibility: draft.lobbyVisibility,
boardCount: draft.boardCount,
enableThirdPlaceMatch: draft.enableThirdPlaceMatch,
participants,
tournamentTimeProfile: settings?.tournamentTimeProfile,
}, settings);
}
function estimateTournamentDurationFromTournament(tournament, settings = null) {
if (!tournament) {
return estimateTournamentDuration(null, settings);
}
const x01Settings = normalizeTournamentX01Settings(tournament?.x01, tournament?.startScore);
return estimateTournamentDuration({
mode: tournament.mode,
bestOfLegs: tournament.bestOfLegs,
startScore: x01Settings.baseScore,
x01Preset: x01Settings.presetId,
x01InMode: x01Settings.inMode,
x01OutMode: x01Settings.outMode,
x01BullMode: x01Settings.bullMode,
x01MaxRounds: x01Settings.maxRounds,
x01BullOffMode: x01Settings.bullOffMode,
lobbyVisibility: x01Settings.lobbyVisibility,
boardCount: tournament?.duration?.boardCount,
enableThirdPlaceMatch: tournament?.ko?.enableThirdPlaceMatch === true,
participants: tournament.participants,
tournamentTimeProfile: settings?.tournamentTimeProfile,
}, settings);
}
function standingsForMatches(tournament, matches, participantIds = null) {
const allowedIds = Array.isArray(participantIds)
? new Set(participantIds.map((id) => normalizeText(id)).filter(Boolean))
: null;
const rows = (tournament?.participants || [])
.filter((participant) => !allowedIds || allowedIds.has(participant.id))
.map((participant) => ({
id: participant.id,
name: participant.name,
played: 0,
wins: 0,
draws: 0,
losses: 0,
legsFor: 0,
legsAgainst: 0,
legDiff: 0,
points: 0,
rank: 0,
tiebreakState: "resolved",
}));
const rowById = new Map(rows.map((row) => [row.id, row]));
const completedMatches = (Array.isArray(matches) ? matches : []).filter((match) => match?.status === STATUS_COMPLETED);
completedMatches.forEach((match) => {
if (!match.player1Id || !match.player2Id) {
return;
}
const row1 = rowById.get(match.player1Id);
const row2 = rowById.get(match.player2Id);
if (!row1 || !row2) {
return;
}
row1.played += 1;
row2.played += 1;
const p1Legs = clampInt(match.legs?.p1, 0, 0, 50);
const p2Legs = clampInt(match.legs?.p2, 0, 0, 50);
row1.legsFor += p1Legs;
row1.legsAgainst += p2Legs;
row2.legsFor += p2Legs;
row2.legsAgainst += p1Legs;
if (match.winnerId === match.player1Id) {
row1.wins += 1;
row2.losses += 1;
row1.points += 2;
return;
}
if (match.winnerId === match.player2Id) {
row2.wins += 1;
row1.losses += 1;
row2.points += 2;
return;
}
row1.draws += 1;
row2.draws += 1;
row1.points += 1;
row2.points += 1;
});
rows.forEach((row) => {
row.legDiff = row.legsFor - row.legsAgainst;
});
const tieBreakProfile = normalizeTieBreakProfile(
tournament?.rules?.tieBreakProfile,
TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE,
);
const tiePrimaryById = new Map(rows.map((row) => [row.id, 0]));
if (tieBreakProfile === TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE) {
const pointsBuckets = new Map();
rows.forEach((row) => {
if (!pointsBuckets.has(row.points)) {
pointsBuckets.set(row.points, []);
}
pointsBuckets.get(row.points).push(row);
});
pointsBuckets.forEach((bucketRows) => {
if (bucketRows.length < 2) {
return;
}
const bucketIds = new Set(bucketRows.map((row) => row.id));
const bucketMatches = completedMatches.filter((match) => (
match.player1Id
&& match.player2Id
&& bucketIds.has(match.player1Id)
&& bucketIds.has(match.player2Id)
));
if (bucketRows.length === 2) {
const left = bucketRows[0];
const right = bucketRows[1];
let leftDirectScore = 0;
let rightDirectScore = 0;
bucketMatches.forEach((match) => {
if (match.winnerId === left.id) {
leftDirectScore += 1;
} else if (match.winnerId === right.id) {
rightDirectScore += 1;
}
});
if (leftDirectScore !== rightDirectScore) {
tiePrimaryById.set(left.id, leftDirectScore - rightDirectScore);
tiePrimaryById.set(right.id, rightDirectScore - leftDirectScore);
}
return;
}
const miniLegDiffById = new Map(bucketRows.map((row) => [row.id, 0]));
bucketMatches.forEach((match) => {
const p1 = clampInt(match.legs?.p1, 0, 0, 50);
const p2 = clampInt(match.legs?.p2, 0, 0, 50);
miniLegDiffById.set(match.player1Id, (miniLegDiffById.get(match.player1Id) || 0) + (p1 - p2));
miniLegDiffById.set(match.player2Id, (miniLegDiffById.get(match.player2Id) || 0) + (p2 - p1));
});
bucketRows.forEach((row) => {
tiePrimaryById.set(row.id, miniLegDiffById.get(row.id) || 0);
});
});
}
rows.sort((left, right) => {
if (right.points !== left.points) {
return right.points - left.points;
}
if (tieBreakProfile === TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE) {
const rightPrimary = tiePrimaryById.get(right.id) || 0;
const leftPrimary = tiePrimaryById.get(left.id) || 0;
if (rightPrimary !== leftPrimary) {
return rightPrimary - leftPrimary;
}
}
if (right.legDiff !== left.legDiff) {
return right.legDiff - left.legDiff;
}
if (right.legsFor !== left.legsFor) {
return right.legsFor - left.legsFor;
}
return left.name.localeCompare(right.name, "de");
});
if (tieBreakProfile === TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE) {
const unresolvedBuckets = new Map();
rows.forEach((row) => {
const key = [
row.points,
tiePrimaryById.get(row.id) || 0,
row.legDiff,
row.legsFor,
].join("|");
if (!unresolvedBuckets.has(key)) {
unresolvedBuckets.set(key, []);
}
unresolvedBuckets.get(key).push(row);
});
unresolvedBuckets.forEach((bucketRows) => {
if (bucketRows.length < 2) {
return;
}
bucketRows.forEach((row) => {
row.tiebreakState = "playoff_required";
});
});
}
rows.forEach((row, index) => {
row.rank = index + 1;
});
return rows;
}
function groupStandingsMap(tournament) {
const map = new Map();
(tournament.groups || []).forEach((group) => {
const groupMatches = tournament.matches.filter((match) => match.stage === MATCH_STAGE_GROUP && match.groupId === group.id);
const rows = standingsForMatches(tournament, groupMatches, group.participantIds);
const complete = groupMatches.length > 0 && groupMatches.every((match) => match.status === STATUS_COMPLETED);
const groupResolution = complete && rows.some((row) => row.tiebreakState === "playoff_required")
? {
status: "playoff_required",
reason: "Playoff erforderlich: Gleichstand nach DRA-Tie-Break.",
}
: {
status: "resolved",
reason: "",
};
map.set(group.id, {
group,
rows,
complete,
groupResolution,
});
});
return map;
}
function resolveGroupsToKoAssignments(tournament) {
if (tournament.mode !== "groups_ko") {
return false;
}
let changed = false;
const standingMap = groupStandingsMap(tournament);
const semifinals = getMatchesByStage(tournament, MATCH_STAGE_KO).filter((match) => match.round === 1);
semifinals.forEach((match) => {
const from1 = match.meta?.from1;
const from2 = match.meta?.from2;
if (!from1 || !from2) {
return;
}
const group1 = standingMap.get(from1.groupId);
const group2 = standingMap.get(from2.groupId);
const p1 = group1 && group1.complete && group1.groupResolution?.status === "resolved"
? group1.rows[from1.rank - 1]?.id || null
: null;
const p2 = group2 && group2.complete && group2.groupResolution?.status === "resolved"
? group2.rows[from2.rank - 1]?.id || null
: null;
changed = assignPlayerSlot(match, 1, p1) || changed;
changed = assignPlayerSlot(match, 2, p2) || changed;
});
return changed;
}
function findKoNextMatch(tournament, match) {
const nextRound = match.round + 1;
const nextNumber = Math.ceil(match.number / 2);
return tournament.matches.find(
(item) => item.stage === MATCH_STAGE_KO && item.round === nextRound && item.number === nextNumber,
) || null;
}
function advanceKoWinners(tournament) {
const koMatches = getMatchesByStage(tournament, MATCH_STAGE_KO);
let changed = false;
koMatches.forEach((match) => {
if (match.status !== STATUS_COMPLETED || !match.winnerId) {
return;
}
const nextMatch = findKoNextMatch(tournament, match);
if (!nextMatch) {
return;
}
if (match.number % 2 === 1) {
changed = assignPlayerSlot(nextMatch, 1, match.winnerId) || changed;
} else {
changed = assignPlayerSlot(nextMatch, 2, match.winnerId) || changed;
}
});
return changed;
}
function serializeComparable(value) {
return JSON.stringify(value === undefined ? null : value);
}
function isSerializableEqual(left, right) {
return serializeComparable(left) === serializeComparable(right);
}
function deriveWinnerIdFromLegs(tournament, match) {
if (!match?.player1Id || !match?.player2Id) {
return null;
}
const legsToWin = getLegsToWin(tournament?.bestOfLegs);
const p1Legs = clampInt(match.legs?.p1, 0, 0, 99);
const p2Legs = clampInt(match.legs?.p2, 0, 0, 99);
if (p1Legs === p2Legs) {
return null;
}
if (p1Legs > legsToWin || p2Legs > legsToWin) {
return null;
}
if (p1Legs === legsToWin && p1Legs > p2Legs) {
return match.player1Id;
}
if (p2Legs === legsToWin && p2Legs > p1Legs) {
return match.player2Id;
}
return null;
}
function resolveVirtualCompetitorParticipantId(competitorRef, winnerByVirtualMatchId, loserByVirtualMatchId) {
if (!competitorRef) {
return null;
}
if (competitorRef.type === "participant") {
return normalizeText(competitorRef.participantId || "") || null;
}
if (competitorRef.type === "winner") {
return winnerByVirtualMatchId.get(normalizeText(competitorRef.matchId || "")) || null;
}
if (competitorRef.type === "loser") {
return loserByVirtualMatchId.get(normalizeText(competitorRef.matchId || "")) || null;
}
return null;
}
function buildKoMetaSnapshot(drawMode, drawLocked, structure) {
const bracketSize = clampInt(structure?.bracketSize, 2, 2, TECHNICAL_PARTICIPANT_HARD_MAX);
const placement = (Array.isArray(structure?.placement) ? structure.placement : [])
.map((slot) => clampInt(slot, null, 1, bracketSize))
.filter((slot) => Number.isInteger(slot));
const normalizedPlacement = placement.length === bracketSize
? placement
: buildSeedPlacement(bracketSize);
const rounds = (Array.isArray(structure?.rounds) ? structure.rounds : []).map((roundDef) => ({
round: clampInt(roundDef?.round, 1, 1, 64),
label: normalizeText(roundDef?.label || ""),
virtualMatches: (Array.isArray(roundDef?.virtualMatches) ? roundDef.virtualMatches : []).map((virtualMatch) => ({
id: normalizeText(virtualMatch?.id || ""),
round: clampInt(virtualMatch?.round, 1, 1, 64),
number: clampInt(virtualMatch?.number, 1, 1, 256),
structuralBye: Boolean(virtualMatch?.structuralBye),
matchRole: normalizeText(virtualMatch?.matchRole || "").toLowerCase() === "third_place"
? "third_place"
: "main",
advancesWinnerTo: normalizeText(virtualMatch?.advancesWinnerTo || "") || null,
advancesLoserTo: normalizeText(virtualMatch?.advancesLoserTo || "") || null,
placementRank: Number.isFinite(Number(virtualMatch?.placementRank))
? clampInt(virtualMatch?.placementRank, null, 1, 128)
: null,
competitors: {
p1: virtualMatch?.competitors?.p1 || null,
p2: virtualMatch?.competitors?.p2 || null,
},
})),
}));
const seeding = (Array.isArray(structure?.seeding) ? structure.seeding : []).map((entry) => ({
participantId: normalizeText(entry?.participantId || ""),
participantName: normalizeText(entry?.participantName || entry?.participantId || ""),
seed: clampInt(entry?.seed, 1, 1, TECHNICAL_PARTICIPANT_HARD_MAX),
hasBye: Boolean(entry?.hasBye),
entryRound: clampInt(entry?.entryRound, 1, 1, 64),
slot: Number.isFinite(Number(entry?.slot)) ? clampInt(entry?.slot, 1, 1, TECHNICAL_PARTICIPANT_HARD_MAX) : null,
})).filter((entry) => entry.participantId);
return {
drawMode: normalizeKoDrawMode(drawMode, KO_DRAW_MODE_SEEDED),
drawLocked: Boolean(drawLocked),
enableThirdPlaceMatch: Boolean(structure?.enableThirdPlaceMatch),
engineVersion: KO_ENGINE_VERSION,
bracketSize,
placement: normalizedPlacement,
seeding,
rounds,
};
}
function buildKoStructureFromMeta(koMeta, fallbackDrawMode = KO_DRAW_MODE_SEEDED) {
const normalized = normalizeTournamentKoMeta(koMeta, fallbackDrawMode, koMeta?.drawLocked !== false);
if (!Array.isArray(normalized?.rounds) || !normalized.rounds.length) {
return null;
}
return {
bracketSize: normalized.bracketSize,
enableThirdPlaceMatch: Boolean(normalized.enableThirdPlaceMatch),
placement: normalized.placement,
seeding: normalized.seeding,
rounds: normalized.rounds,
};
}
function synchronizeStructuralByeMatch(match, p1, p2) {
const hasP1 = Boolean(p1);
const hasP2 = Boolean(p2);
const hasExactlyOneParticipant = (hasP1 && !hasP2) || (!hasP1 && hasP2);
let changed = false;
if (!hasExactlyOneParticipant) {
if (isByeMatchResult(match) || match.status === STATUS_COMPLETED) {
clearMatchResult(match);
changed = true;
}
return changed;
}
const expectedWinnerId = p1 || p2;
if (match.status !== STATUS_COMPLETED) {
match.status = STATUS_COMPLETED;
changed = true;
}
if (match.winnerId !== expectedWinnerId) {
match.winnerId = expectedWinnerId;
changed = true;
}
if (match.source !== null) {
match.source = null;
changed = true;
}
if (clampInt(match.legs?.p1, 0, 0, 99) !== 0 || clampInt(match.legs?.p2, 0, 0, 99) !== 0) {
match.legs = { p1: 0, p2: 0 };
changed = true;
}
const normalizedStats = normalizeMatchStats(match.stats);
if (!isSerializableEqual(match.stats, normalizedStats)) {
match.stats = normalizedStats;
changed = true;
}
changed = setMatchResultKind(match, "bye") || changed;
const auto = ensureMatchAutoMeta(match);
if (
auto.lobbyId
|| auto.status !== "idle"
|| auto.startedAt
|| auto.finishedAt
|| auto.lastSyncAt
|| auto.lastError
) {
resetMatchAutomationMeta(match);
changed = true;
}
if (changed) {
match.updatedAt = nowIso();
}
return changed;
}
function synchronizeKoBracketState(tournament) {
if (!tournament || tournament.mode !== "ko") {
return false;
}
let changed = false;
const drawMode = normalizeKoDrawMode(tournament?.ko?.drawMode, KO_DRAW_MODE_SEEDED);
const drawLocked = tournament?.ko?.drawLocked !== false;
const enableThirdPlaceMatch = tournament?.ko?.enableThirdPlaceMatch === true;
const participants = (Array.isArray(tournament.participants) ? tournament.participants : [])
.map((participant) => ({
id: normalizeText(participant?.id || ""),
name: normalizeText(participant?.name || participant?.id || ""),
seed: participant?.seed,
}))
.filter((participant) => participant.id);
const generatedStructure = buildBracketStructure(
participants,
generateSeeds(participants, drawMode),
{ enableThirdPlaceMatch },
);
const lockedStructure = drawLocked ? buildKoStructureFromMeta(tournament?.ko, drawMode) : null;
const structure = lockedStructure || generatedStructure;
const nextKoMeta = buildKoMetaSnapshot(drawMode, drawLocked, structure);
if (!isSerializableEqual(tournament.ko, nextKoMeta)) {
tournament.ko = nextKoMeta;
changed = true;
}
const existingKoMatches = getMatchesByStage(tournament, MATCH_STAGE_KO);
const existingKoById = new Map(existingKoMatches.map((match) => [match.id, match]));
const winnerByVirtualMatchId = new Map();
const loserByVirtualMatchId = new Map();
const nextKoMatches = [];
structure.rounds.forEach((roundDef) => {
roundDef.virtualMatches.forEach((virtualMatch) => {
const p1 = resolveVirtualCompetitorParticipantId(
virtualMatch?.competitors?.p1,
winnerByVirtualMatchId,
loserByVirtualMatchId,
);
const p2 = resolveVirtualCompetitorParticipantId(
virtualMatch?.competitors?.p2,
winnerByVirtualMatchId,
loserByVirtualMatchId,
);
const structuralBye = Boolean(virtualMatch?.structuralBye);
let match = existingKoById.get(virtualMatch.id) || null;
if (!match) {
match = createMatch({
id: virtualMatch.id,
stage: MATCH_STAGE_KO,
round: virtualMatch.round,
number: virtualMatch.number,
player1Id: p1,
player2Id: p2,
meta: buildKoMatchMetaFromVirtualMatch(virtualMatch),
});
if (structuralBye) {
synchronizeStructuralByeMatch(match, p1, p2);
}
changed = true;
} else {
if (match.round !== virtualMatch.round || match.number !== virtualMatch.number) {
match.round = virtualMatch.round;
match.number = virtualMatch.number;
match.updatedAt = nowIso();
changed = true;
}
changed = assignPlayerSlot(match, 1, p1) || changed;
changed = assignPlayerSlot(match, 2, p2) || changed;
if (structuralBye) {
changed = synchronizeStructuralByeMatch(match, p1, p2) || changed;
} else if (isByeMatchResult(match)) {
const localChanged = setMatchResultKind(match, null);
if (localChanged) {
match.updatedAt = nowIso();
}
changed = localChanged || changed;
}
const normalizedMeta = normalizeMatchMeta({
...(match.meta || {}),
...buildKoMatchMetaFromVirtualMatch(virtualMatch),
});
if (!isSerializableEqual(match.meta, normalizedMeta)) {
match.meta = normalizedMeta;
match.updatedAt = nowIso();
changed = true;
}
}
const normalizedStats = normalizeMatchStats(match.stats);
if (!isSerializableEqual(match.stats, normalizedStats)) {
match.stats = normalizedStats;
match.updatedAt = nowIso();
changed = true;
}
nextKoMatches.push(match);
if (match.status === STATUS_COMPLETED) {
if (!isByeMatchResult(match)) {
const derivedWinnerId = deriveWinnerIdFromLegs(tournament, match);
if (derivedWinnerId && match.winnerId !== derivedWinnerId) {
match.winnerId = derivedWinnerId;
match.updatedAt = nowIso();
changed = true;
}
}
if (isCompletedMatchResultValid(tournament, match)) {
winnerByVirtualMatchId.set(match.id, match.winnerId);
if (!isByeMatchResult(match) && match.player1Id && match.player2Id) {
const loserId = match.winnerId === match.player1Id
? match.player2Id
: (match.winnerId === match.player2Id ? match.player1Id : null);
if (loserId) {
loserByVirtualMatchId.set(match.id, loserId);
}
}
}
} else if (structuralBye) {
const advancedParticipant = p1 || p2 || null;
if (advancedParticipant) {
winnerByVirtualMatchId.set(virtualMatch.id, advancedParticipant);
}
}
});
});
const nextKoMatchIdSet = new Set(nextKoMatches.map((match) => match.id));
if (existingKoMatches.some((match) => !nextKoMatchIdSet.has(match.id))) {
changed = true;
}
const nonKoMatches = (tournament.matches || []).filter((match) => match.stage !== MATCH_STAGE_KO);
const mergedMatches = nonKoMatches.concat(nextKoMatches);
const currentMatches = Array.isArray(tournament.matches) ? tournament.matches : [];
if (currentMatches.length !== mergedMatches.length) {
changed = true;
} else {
for (let i = 0; i < currentMatches.length; i += 1) {
if (currentMatches[i]?.id !== mergedMatches[i]?.id) {
changed = true;
break;
}
}
}
tournament.matches = mergedMatches;
return changed;
}
function migrateKoTournamentToV3(tournament, defaultDrawMode = KO_DRAW_MODE_SEEDED) {
if (!tournament || tournament.mode !== "ko") {
return false;
}
const drawMode = normalizeKoDrawMode(tournament?.ko?.drawMode, defaultDrawMode);
const drawLocked = tournament?.ko?.drawLocked !== false;
const engineVersion = normalizeKoEngineVersion(tournament?.ko?.engineVersion, 0);
const currentKo = tournament.ko && typeof tournament.ko === "object" ? tournament.ko : {};
const normalizedKo = normalizeTournamentKoMeta(currentKo, drawMode, drawLocked);
if (engineVersion >= KO_ENGINE_VERSION) {
const nextKo = {
...normalizedKo,
drawMode,
drawLocked,
engineVersion: KO_ENGINE_VERSION,
};
if (!isSerializableEqual(currentKo, nextKo)) {
tournament.ko = nextKo;
return true;
}
return false;
}
tournament.ko = {
...normalizedKo,
drawMode,
drawLocked,
engineVersion: KO_ENGINE_VERSION,
};
tournament.updatedAt = nowIso();
return true;
}
function isCompletedMatchResultValid(tournament, match) {
if (!match || match.status !== STATUS_COMPLETED) {
return true;
}
if (isByeMatchResult(match)) {
const hasP1 = Boolean(match.player1Id);
const hasP2 = Boolean(match.player2Id);
if (hasP1 === hasP2) {
return false;
}
const expectedWinnerId = hasP1 ? match.player1Id : match.player2Id;
const p1Legs = clampInt(match.legs?.p1, 0, 0, 99);
const p2Legs = clampInt(match.legs?.p2, 0, 0, 99);
return normalizeText(match.winnerId || "") === normalizeText(expectedWinnerId || "")
&& p1Legs === 0
&& p2Legs === 0;
}
if (!match.player1Id || !match.player2Id) {
return false;
}
const derivedWinnerId = deriveWinnerIdFromLegs(tournament, match);
if (!derivedWinnerId) {
return false;
}
if (!match.winnerId) {
return false;
}
return match.winnerId === derivedWinnerId;
}
function normalizeCompletedMatchResults(tournament) {
if (!tournament) {
return false;
}
let changed = false;
tournament.matches.forEach((match) => {
const normalizedStats = normalizeMatchStats(match.stats);
if (!isSerializableEqual(match.stats, normalizedStats)) {
match.stats = normalizedStats;
match.updatedAt = nowIso();
changed = true;
}
if (match.status !== STATUS_COMPLETED) {
return;
}
if (isByeMatchResult(match)) {
changed = synchronizeStructuralByeMatch(match, match.player1Id || null, match.player2Id || null) || changed;
return;
}
const derivedWinnerId = deriveWinnerIdFromLegs(tournament, match);
if (!derivedWinnerId || !match.player1Id || !match.player2Id) {
clearMatchResult(match);
changed = true;
return;
}
if (match.winnerId !== derivedWinnerId) {
match.winnerId = derivedWinnerId;
match.updatedAt = nowIso();
changed = true;
}
});
return changed;
}
function buildTournamentResults(tournament) {
const stageOrder = new Map([
[MATCH_STAGE_GROUP, 1],
[MATCH_STAGE_LEAGUE, 2],
[MATCH_STAGE_KO, 3],
]);
return (Array.isArray(tournament?.matches) ? tournament.matches : [])
.filter((match) => match?.status === STATUS_COMPLETED && isCompletedMatchResultValid(tournament, match))
.map((match) => ({
matchId: match.id,
stage: match.stage,
round: match.round,
number: match.number,
player1Id: match.player1Id,
player2Id: match.player2Id,
winnerId: match.winnerId,
legs: {
p1: clampInt(match.legs?.p1, 0, 0, 99),
p2: clampInt(match.legs?.p2, 0, 0, 99),
},
stats: normalizeMatchStats(match.stats),
source: match.source === "auto" ? "auto" : "manual",
updatedAt: normalizeText(match.updatedAt || nowIso()),
}))
.sort((left, right) => (
(stageOrder.get(left.stage) || 99) - (stageOrder.get(right.stage) || 99)
|| left.round - right.round
|| left.number - right.number
));
}
function refreshTournamentResultsIndex(tournament) {
if (!tournament) {
return false;
}
const nextResults = buildTournamentResults(tournament);
if (isSerializableEqual(tournament.results, nextResults)) {
return false;
}
tournament.results = nextResults;
return true;
}
function getOpenMatchByPlayers(tournament, player1Id, player2Id) {
const key = new Set([player1Id, player2Id]);
const candidates = tournament.matches.filter((match) => {
if (match.status !== STATUS_PENDING) {
return false;
}
if (!match.player1Id || !match.player2Id) {
return false;
}
const set = new Set([match.player1Id, match.player2Id]);
return key.size === set.size && [...key].every((id) => set.has(id));
});
return candidates.length === 1 ? candidates[0] : null;
}
function deriveWinnerIdFromLegInput(match, p1Legs, p2Legs, legsToWin) {
if (!match?.player1Id || !match?.player2Id) {
return null;
}
if (p1Legs === p2Legs) {
return null;
}
if (p1Legs > legsToWin || p2Legs > legsToWin) {
return null;
}
if (p1Legs === legsToWin && p1Legs > p2Legs) {
return match.player1Id;
}
if (p2Legs === legsToWin && p2Legs > p1Legs) {
return match.player2Id;
}
return null;
}
function applyMatchResultToTournament(tournament, matchId, winnerId, legs, source, stats = null) {
if (!tournament) {
return { ok: false, message: "Kein aktives Turnier vorhanden." };
}
const match = findMatch(tournament, matchId);
if (!match) {
return { ok: false, message: "Match nicht gefunden." };
}
if (!match.player1Id || !match.player2Id) {
return { ok: false, message: "Match hat noch keine zwei Teilnehmer." };
}
if (winnerId && winnerId !== match.player1Id && winnerId !== match.player2Id) {
return { ok: false, message: "Gewinner passt nicht zum Match." };
}
const legsToWin = getLegsToWin(tournament.bestOfLegs);
const p1Legs = clampInt(legs?.p1, 0, 0, 99);
const p2Legs = clampInt(legs?.p2, 0, 0, 99);
const derivedWinnerId = deriveWinnerIdFromLegInput(match, p1Legs, p2Legs, legsToWin);
if (p1Legs > legsToWin || p2Legs > legsToWin) {
return {
ok: false,
message: `Ung\u00fcltiges Ergebnis: Pro Spieler sind maximal ${legsToWin} Legs m\u00f6glich (Best-of ${sanitizeBestOf(tournament.bestOfLegs)}).`,
};
}
if (p1Legs === p2Legs) {
return { ok: false, message: "Ung\u00fcltiges Ergebnis: Bei Best-of ist kein Gleichstand m\u00f6glich." };
}
if (!derivedWinnerId) {
return {
ok: false,
message: `Ung\u00fcltiges Ergebnis: Ein Spieler muss genau ${legsToWin} Legs erreichen (Best-of ${sanitizeBestOf(tournament.bestOfLegs)}).`,
};
}
if (winnerId && winnerId !== derivedWinnerId) {
return {
ok: false,
message: "Ung\u00fcltiges Ergebnis: Gewinner muss aus den Legs abgeleitet werden.",
};
}
match.status = STATUS_COMPLETED;
match.winnerId = derivedWinnerId;
match.source = source === "auto" ? "auto" : "manual";
match.legs = { p1: p1Legs, p2: p2Legs };
match.stats = normalizeMatchStats(stats || match.stats);
setMatchResultKind(match, null);
const now = nowIso();
const auto = ensureMatchAutoMeta(match);
if (source === "auto") {
auto.status = "completed";
auto.finishedAt = now;
auto.lastSyncAt = now;
auto.lastError = null;
} else if (auto.lobbyId || auto.status === "started" || auto.status === "error") {
auto.status = "completed";
auto.finishedAt = now;
auto.lastSyncAt = now;
auto.lastError = null;
}
match.updatedAt = now;
return { ok: true };
}
function getKoBlockingSourceMatch(tournament, match) {
if (!tournament || !match || match.stage !== MATCH_STAGE_KO || match.round <= 1) {
return null;
}
const sourceMatchIds = new Set();
const p1SourceType = normalizeText(match?.meta?.bracket?.p1Source?.type || "");
const p2SourceType = normalizeText(match?.meta?.bracket?.p2Source?.type || "");
const p1SourceMatchId = normalizeText(match?.meta?.bracket?.p1Source?.matchId || "");
const p2SourceMatchId = normalizeText(match?.meta?.bracket?.p2Source?.matchId || "");
if ((p1SourceType === "winner" || p1SourceType === "loser") && p1SourceMatchId) {
sourceMatchIds.add(p1SourceMatchId);
}
if ((p2SourceType === "winner" || p2SourceType === "loser") && p2SourceMatchId) {
sourceMatchIds.add(p2SourceMatchId);
}
if (sourceMatchIds.size) {
const sourceMatches = getMatchesByStage(tournament, MATCH_STAGE_KO)
.filter((item) => sourceMatchIds.has(normalizeText(item?.id || "")));
return sourceMatches.find((item) => item.status !== STATUS_COMPLETED) || null;
}
const previousRound = match.round - 1;
const sourceNumberA = ((match.number - 1) * 2) + 1;
const sourceNumberB = sourceNumberA + 1;
const sourceMatches = getMatchesByStage(tournament, MATCH_STAGE_KO)
.filter((item) => (
item.round === previousRound
&& (item.number === sourceNumberA || item.number === sourceNumberB)
))
.sort((left, right) => left.number - right.number);
if (!sourceMatches.length) {
return null;
}
return sourceMatches.find((item) => item.status !== STATUS_COMPLETED) || null;
}
function getMatchEditability(tournament, match) {
if (!tournament || !match) {
return { editable: false, reason: "Match nicht verf\u00fcgbar." };
}
if (match.status === STATUS_COMPLETED) {
return { editable: false, reason: "Match ist bereits abgeschlossen." };
}
if (!match.player1Id || !match.player2Id) {
return { editable: false, reason: "Paarung steht noch nicht fest." };
}
if (match.stage === MATCH_STAGE_KO) {
const blockingMatch = getKoBlockingSourceMatch(tournament, match);
if (blockingMatch) {
const koFinalRound = getMatchesByStage(tournament, MATCH_STAGE_KO).reduce((maxRound, koMatch) => {
if (normalizeText(koMatch?.meta?.bracket?.matchRole || "") === "third_place") {
return maxRound;
}
const roundNumber = clampInt(koMatch?.round, 0, 0, 64);
return roundNumber > maxRound ? roundNumber : maxRound;
}, 0);
const blockingLabel = getKoRoundMatchLabel(
blockingMatch.round,
koFinalRound || blockingMatch.round,
blockingMatch.number,
);
return {
editable: false,
reason: `Vorg\u00e4nger-Match ${blockingLabel} muss zuerst abgeschlossen werden.`,
};
}
}
return { editable: true, reason: "" };
}
function buildBracketPayload(tournament) {
const koMatches = getMatchesByStage(tournament, MATCH_STAGE_KO);
if (!koMatches.length) {
return null;
}
const bracketSize = tournament.mode === "groups_ko"
? 4
: nextPowerOfTwo(clampInt(tournament?.ko?.bracketSize, tournament.participants.length, 2, TECHNICAL_PARTICIPANT_HARD_MAX));
const participants = tournament.participants
.map((participant) => {
const participantId = normalizeText(participant?.id);
if (!participantId) {
return null;
}
return {
id: participantId,
tournament_id: 1,
name: normalizeText(participant?.name) || participantId,
};
})
.filter(Boolean);
const participantIdSet = new Set(participants.map((participant) => participant.id));
const participantIndexes = buildParticipantIndexes(tournament);
const resolveBracketParticipantId = (slotId) => {
const resolved = resolveParticipantSlotId(tournament, slotId, participantIndexes);
if (!resolved) {
return null;
}
const participantId = normalizeText(resolved);
return participantIdSet.has(participantId) ? participantId : null;
};
const isThirdPlaceMatch = (match) => normalizeText(match?.meta?.bracket?.matchRole || "") === "third_place";
const hasThirdPlaceMatch = koMatches.some((match) => isThirdPlaceMatch(match));
const matches = koMatches.map((match) => {
const player1Id = resolveBracketParticipantId(match.player1Id);
const player2Id = resolveBracketParticipantId(match.player2Id);
const winnerId = resolveBracketParticipantId(match.winnerId);
const isBye = isByeMatchResult(match);
const completed = isCompletedMatchResultValid(tournament, match)
&& Boolean(winnerId && (winnerId === player1Id || winnerId === player2Id));
const occupied = Boolean(player1Id || player2Id);
const status = completed
? 4
: (occupied ? 2 : 1);
const opponent1 = player1Id
? {
id: player1Id,
score: completed ? clampInt(match.legs?.p1, 0, 0, 99) : undefined,
result: completed && winnerId
? (winnerId === player1Id ? "win" : (isBye ? undefined : "loss"))
: undefined,
}
: null;
const opponent2 = player2Id
? {
id: player2Id,
score: completed ? clampInt(match.legs?.p2, 0, 0, 99) : undefined,
result: completed && winnerId
? (winnerId === player2Id ? "win" : (isBye ? undefined : "loss"))
: undefined,
}
: null;
return {
id: match.id,
stage_id: 1,
group_id: isThirdPlaceMatch(match) ? 2 : 1,
round_id: match.round,
number: match.number,
child_count: 0,
status,
opponent1,
opponent2,
};
});
return {
stages: [{
id: 1,
tournament_id: 1,
name: tournament.mode === "groups_ko" ? "KO-Phase" : "KO",
type: "single_elimination",
settings: {
size: bracketSize,
consolationFinal: hasThirdPlaceMatch,
},
number: 1,
}],
matches,
matchGames: [],
participants,
};
}
function buildBracketFrameSrcdoc() {
return `
Turnierbaum wird geladen ...
`;
}
function applyBracketFrameHeight(frame, bracketState, height) {
if (!(frame instanceof HTMLIFrameElement)) {
return;
}
const nextHeight = clampInt(height, 0, 420, 12000);
if (!nextHeight || Math.abs(nextHeight - bracketState.frameHeight) < 2) {
return;
}
bracketState.frameHeight = nextHeight;
frame.style.height = `${nextHeight}px`;
}
function resolveBracketFrameElement(shadowRoot) {
if (!shadowRoot) {
return null;
}
const frame = shadowRoot.getElementById("ata-bracket-frame");
return frame instanceof HTMLIFrameElement ? frame : null;
}
function resetBracketFrameState(bracketState, frame, srcdoc) {
bracketState.ready = false;
bracketState.failed = false;
bracketState.frameHeight = 0;
bracketState.lastError = "";
frame.style.removeProperty("height");
frame.srcdoc = srcdoc;
}
function clearBracketFrameTimeout(bracketState) {
if (bracketState.timeoutHandle) {
clearTimeout(bracketState.timeoutHandle);
bracketState.timeoutHandle = null;
}
}
function armBracketFrameTimeout(bracketState, onTimeout, timeoutMs) {
bracketState.timeoutHandle = window.setTimeout(() => {
bracketState.timeoutHandle = null;
onTimeout();
}, timeoutMs);
}
function postBracketRenderPayload(frame, payload) {
if (frame instanceof HTMLIFrameElement && frame.contentWindow) {
frame.contentWindow.postMessage({ type: "ata:render-bracket", payload }, "*");
}
}
function readBracketFrameMessage(event, frame) {
if (!(frame instanceof HTMLIFrameElement) || event.source !== frame.contentWindow) {
return null;
}
const data = event.data;
if (!data || typeof data !== "object") {
return null;
}
return data;
}
// App layer: runtime orchestration, persistence scheduling and user feedback.
function setNotice(type, message, timeoutMs = 4500) {
state.notice = { type, message: String(message || "") };
renderShell();
if (state.noticeTimer) {
clearTimeout(state.noticeTimer);
state.noticeTimer = null;
}
if (timeoutMs > 0 && state.notice.message) {
state.noticeTimer = window.setTimeout(() => {
state.notice = { type: "info", message: "" };
renderShell();
}, timeoutMs);
}
}
// App layer: runtime orchestration, persistence scheduling and user feedback.
async function loadPersistedStore() {
const raw = await readStoreValue(STORAGE_KEY, createDefaultStore());
state.store = migrateStorage(raw);
state.activeTab = state.store.ui.activeTab;
const needsSchemaWriteback = Number(raw?.schemaVersion || 0) !== STORAGE_SCHEMA_VERSION;
if (state.store.tournament) {
const changed = refreshDerivedMatches(state.store.tournament);
if (changed || needsSchemaWriteback) {
state.store.tournament.updatedAt = nowIso();
schedulePersist();
}
} else if (needsSchemaWriteback) {
schedulePersist();
}
logDebug("storage", "Store loaded", state.store);
}
function schedulePersist() {
if (state.saveTimer) {
clearTimeout(state.saveTimer);
state.saveTimer = null;
}
state.saveTimer = window.setTimeout(() => {
state.saveTimer = null;
persistStore().catch((error) => {
logError("storage", "Persisting store failed.", error);
});
}, SAVE_DEBOUNCE_MS);
}
async function persistStore() {
state.store.schemaVersion = STORAGE_SCHEMA_VERSION;
state.store.ui.activeTab = state.activeTab;
await writeStoreValue(STORAGE_KEY, state.store);
}
// App layer: runtime orchestration, persistence scheduling and user feedback.
function maybePersistKoMigrationBackup(tournament, defaultDrawMode = KO_DRAW_MODE_SEEDED) {
if (!tournament || tournament.mode !== "ko") {
return;
}
const drawMode = normalizeKoDrawMode(tournament?.ko?.drawMode, defaultDrawMode);
const drawLocked = tournament?.ko?.drawLocked !== false;
const engineVersion = normalizeKoEngineVersion(tournament?.ko?.engineVersion, 0);
if (engineVersion >= KO_ENGINE_VERSION) {
return;
}
const normalizedKo = normalizeTournamentKoMeta(tournament?.ko, drawMode, drawLocked);
const nextKo = {
...normalizedKo,
drawMode,
drawLocked,
engineVersion: KO_ENGINE_VERSION,
};
if (isSerializableEqual(tournament.ko, nextKo)) {
return;
}
const backupSnapshot = cloneSerializable(tournament);
if (!backupSnapshot) {
return;
}
persistKoMigrationBackup(backupSnapshot, "ko-engine-v3-migration").catch((error) => {
logWarn("storage", "KO migration backup write failed.", error);
});
}
function refreshDerivedMatches(tournament) {
if (!tournament) {
return false;
}
let changedAny = false;
for (let i = 0; i < 8; i += 1) {
let changed = false;
maybePersistKoMigrationBackup(tournament, KO_DRAW_MODE_SEEDED);
changed = migrateKoTournamentToV3(tournament, KO_DRAW_MODE_SEEDED) || changed;
changed = resolveGroupsToKoAssignments(tournament) || changed;
changed = synchronizeKoBracketState(tournament) || changed;
changed = normalizeCompletedMatchResults(tournament) || changed;
changed = synchronizeKoBracketState(tournament) || changed;
if (tournament.mode === "groups_ko") {
changed = advanceKoWinners(tournament) || changed;
}
changed = refreshTournamentResultsIndex(tournament) || changed;
changedAny = changedAny || changed;
if (!changed) {
break;
}
}
return changedAny;
}
// App layer: runtime orchestration, persistence scheduling and user feedback.
function updateMatchResult(matchId, winnerId, legs, source, stats = null) {
const tournament = state.store.tournament;
if (!tournament) {
return { ok: false, message: "Kein aktives Turnier vorhanden." };
}
const result = applyMatchResultToTournament(tournament, matchId, winnerId, legs, source, stats);
if (!result.ok) {
return result;
}
refreshDerivedMatches(tournament);
tournament.updatedAt = nowIso();
schedulePersist();
renderShell();
return { ok: true };
}
// App layer: runtime orchestration, persistence scheduling and user feedback.
const DRAW_UNLOCK_OVERRIDE_WINDOW_MS = 30000;
function clearTransientMatchShortcutState() {
state.matchReturnShortcut.pendingDrawUnlockOverride = null;
state.matchReturnShortcut.pendingConfirmationByLobby = {};
}
function getPendingDrawUnlockOverrideForTournament(tournamentId) {
const targetTournamentId = normalizeText(tournamentId || "");
const pending = state.matchReturnShortcut?.pendingDrawUnlockOverride;
if (!targetTournamentId || !pending || typeof pending !== "object") {
return null;
}
const pendingTournamentId = normalizeText(pending.tournamentId || "");
const pendingToken = normalizeText(pending.token || "");
const expiresAt = Number(pending.expiresAt || 0);
if (!pendingToken || pendingTournamentId !== targetTournamentId) {
return null;
}
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
state.matchReturnShortcut.pendingDrawUnlockOverride = null;
return null;
}
return {
token: pendingToken,
expiresAt,
ttlMs: Math.max(0, expiresAt - Date.now()),
};
}
function issuePendingDrawUnlockOverride(tournamentId) {
const targetTournamentId = normalizeText(tournamentId || "");
if (!targetTournamentId) {
return null;
}
const pending = {
tournamentId: targetTournamentId,
token: `${targetTournamentId}:${uuid("draw_unlock_override")}`,
expiresAt: Date.now() + DRAW_UNLOCK_OVERRIDE_WINDOW_MS,
};
state.matchReturnShortcut.pendingDrawUnlockOverride = pending;
return {
token: pending.token,
expiresAt: pending.expiresAt,
ttlMs: DRAW_UNLOCK_OVERRIDE_WINDOW_MS,
};
}
function clearPendingDrawUnlockOverride(tournamentId = "") {
const pending = state.matchReturnShortcut?.pendingDrawUnlockOverride;
if (!pending || typeof pending !== "object") {
return;
}
const targetTournamentId = normalizeText(tournamentId || "");
if (!targetTournamentId || normalizeText(pending.tournamentId || "") === targetTournamentId) {
state.matchReturnShortcut.pendingDrawUnlockOverride = null;
}
}
function finalizeTournamentMutation(tournament, activeTab = state.activeTab) {
if (!tournament) {
return;
}
refreshDerivedMatches(tournament);
tournament.updatedAt = nowIso();
state.activeTab = activeTab;
state.store.ui.activeTab = activeTab;
schedulePersist();
renderShell();
}
function createTournamentSession(config) {
const errors = validateCreateConfig(config);
if (errors.length) {
return { ok: false, message: errors.join(" ") };
}
const tournament = createTournament(config);
refreshDerivedMatches(tournament);
tournament.updatedAt = nowIso();
state.store.tournament = tournament;
clearTransientMatchShortcutState();
state.activeTab = "matches";
state.store.ui.activeTab = "matches";
schedulePersist();
renderShell();
return { ok: true, tournament };
}
function resetTournamentSession() {
state.store.tournament = null;
clearTransientMatchShortcutState();
state.apiAutomation.startingMatchId = "";
state.apiAutomation.authBackoffUntil = 0;
state.activeTab = "tournament";
state.store.ui.activeTab = "tournament";
schedulePersist();
renderShell();
return { ok: true };
}
function importTournamentPayload(rawObject) {
if (!rawObject || typeof rawObject !== "object") {
return { ok: false, message: "JSON ist leer oder ungültig." };
}
let tournament = rawObject.tournament || null;
if (!tournament && rawObject.mode && rawObject.participants) {
tournament = rawObject;
}
const normalizedTournament = normalizeTournament(
tournament,
state.store.settings.featureFlags.koDrawLockDefault !== false,
);
if (!normalizedTournament) {
return { ok: false, message: "Turnierdaten konnten nicht validiert werden." };
}
const participantCountError = getParticipantCountError(normalizedTournament.mode, normalizedTournament.participants.length);
if (participantCountError) {
return { ok: false, message: participantCountError };
}
refreshDerivedMatches(normalizedTournament);
normalizedTournament.updatedAt = nowIso();
state.store.tournament = normalizedTournament;
clearTransientMatchShortcutState();
state.activeTab = "matches";
state.store.ui.activeTab = "matches";
schedulePersist();
renderShell();
return { ok: true, tournament: normalizedTournament };
}
function setTournamentTieBreakProfile(profile) {
const tournament = state.store.tournament;
if (!tournament) {
return { ok: false, message: "Kein aktives Turnier vorhanden." };
}
const result = applyTournamentTieBreakProfile(tournament, profile);
if (!result.ok || !result.changed) {
return result;
}
finalizeTournamentMutation(tournament);
return result;
}
function setTournamentKoDrawLocked(drawLocked, options = {}) {
const tournament = state.store.tournament;
if (!tournament) {
return { ok: false, message: "Kein aktives Turnier vorhanden." };
}
const currentDrawLocked = tournament?.ko?.drawLocked !== false;
const nextDrawLocked = Boolean(drawLocked);
const pendingOverride = getPendingDrawUnlockOverrideForTournament(tournament.id);
const requestedToken = normalizeText(options?.confirmOverrideToken || "");
let allowUnlockOverride = false;
if (currentDrawLocked && !nextDrawLocked && requestedToken) {
if (!pendingOverride || pendingOverride.token !== requestedToken) {
return {
ok: false,
changed: false,
reasonCode: "draw_unlock_override_invalid",
message: "Promoter-Override ist ungültig oder abgelaufen. Bitte Entsperren erneut starten.",
};
}
allowUnlockOverride = true;
}
const result = applyTournamentKoDrawLocked(tournament, nextDrawLocked, {
allowUnlockOverride,
});
if (!result.ok && result.reasonCode === "draw_unlock_requires_override") {
const override = issuePendingDrawUnlockOverride(tournament.id);
return {
...result,
override,
};
}
if (!result.ok || !result.changed) {
return result;
}
clearPendingDrawUnlockOverride(tournament.id);
finalizeTournamentMutation(tournament);
return result;
}
// App layer: runtime orchestration, persistence scheduling and user feedback.
function compareMatchesByRound(left, right) {
const stageOrder = { group: 1, league: 2, ko: 3 };
const leftOrder = stageOrder[left.stage] || 99;
const rightOrder = stageOrder[right.stage] || 99;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.round - right.round || left.number - right.number;
}
function getMatchPriorityReadyFirst(tournament, match) {
const auto = ensureMatchAutoMeta(match);
const playability = getMatchEditability(tournament, match);
if (match.status === STATUS_PENDING && auto.status === "started" && auto.lobbyId) {
return 0;
}
if (match.status === STATUS_PENDING && playability.editable) {
return 1;
}
if (match.status === STATUS_COMPLETED && !isByeMatchResult(match)) {
return 2;
}
if (match.status === STATUS_COMPLETED && isByeMatchResult(match)) {
return 3;
}
return 4;
}
function getMatchPriorityStatus(tournament, match) {
const playability = getMatchEditability(tournament, match);
if (match.status === STATUS_PENDING && playability.editable) {
return 0;
}
if (match.status === STATUS_PENDING) {
return 1;
}
if (isByeMatchResult(match)) {
return 3;
}
return 2;
}
function sortMatchesForDisplay(tournament, sortMode) {
const mode = sanitizeMatchesSortMode(sortMode, MATCH_SORT_MODE_READY_FIRST);
const source = Array.isArray(tournament?.matches) ? tournament.matches.slice() : [];
if (mode === MATCH_SORT_MODE_ROUND) {
return source.sort(compareMatchesByRound);
}
if (mode === MATCH_SORT_MODE_STATUS) {
return source.sort((left, right) => {
const leftPriority = getMatchPriorityStatus(tournament, left);
const rightPriority = getMatchPriorityStatus(tournament, right);
if (leftPriority !== rightPriority) {
return leftPriority - rightPriority;
}
return compareMatchesByRound(left, right);
});
}
return source.sort((left, right) => {
const leftPriority = getMatchPriorityReadyFirst(tournament, left);
const rightPriority = getMatchPriorityReadyFirst(tournament, right);
if (leftPriority !== rightPriority) {
return leftPriority - rightPriority;
}
return compareMatchesByRound(left, right);
});
}
function findSuggestedNextMatch(tournament) {
const source = Array.isArray(tournament?.matches) ? tournament.matches.slice() : [];
const candidates = source
.filter((match) => {
if (!match || match.status !== STATUS_PENDING) {
return false;
}
const playability = getMatchEditability(tournament, match);
if (!playability.editable) {
return false;
}
const auto = ensureMatchAutoMeta(match);
return !(auto.status === "started" && auto.lobbyId);
})
.sort(compareMatchesByRound);
return candidates[0] || null;
}
// App layer: runtime orchestration, persistence scheduling and user feedback.
function syncBracketFallbackVisibility() {
const shadow = state.shadowRoot;
if (!shadow || state.activeTab !== "view") {
return;
}
const fallback = shadow.getElementById("ata-bracket-fallback");
if (!(fallback instanceof HTMLElement)) {
return;
}
fallback.setAttribute("data-visible", state.bracket.failed ? "1" : "0");
}
function queueBracketRender(forceReload = false) {
const tournament = state.store.tournament;
if (!tournament || (tournament.mode !== "ko" && tournament.mode !== "groups_ko")) {
return;
}
const frame = resolveBracketFrameElement(state.shadowRoot);
if (!(frame instanceof HTMLIFrameElement)) {
return;
}
const payload = buildBracketPayload(tournament);
if (!payload) {
return;
}
if (forceReload || state.bracket.iframe !== frame) {
state.bracket.iframe = frame;
resetBracketFrameState(state.bracket, frame, buildBracketFrameSrcdoc());
syncBracketFallbackVisibility();
}
clearBracketFrameTimeout(state.bracket);
armBracketFrameTimeout(state.bracket, () => {
state.bracket.failed = true;
state.bracket.lastError = "Turnierbaum-Render-Timeout";
syncBracketFallbackVisibility();
setNotice("error", "CDN-Turnierbaum-Timeout, Fallback bleibt aktiv.", 3200);
logWarn("bracket", "Iframe bracket render timeout.");
}, 7000);
if (state.bracket.ready) {
postBracketRenderPayload(frame, payload);
}
}
function handleBracketMessage(event) {
const frame = state.bracket.iframe;
const data = readBracketFrameMessage(event, frame);
if (!data) {
return;
}
if (data.type === "ata:bracket-frame-ready") {
state.bracket.ready = true;
const payload = buildBracketPayload(state.store.tournament);
if (payload) {
postBracketRenderPayload(frame, payload);
}
return;
}
if (data.type === "ata:bracket-frame-height") {
applyBracketFrameHeight(frame, state.bracket, data.height);
return;
}
if (data.type === "ata:bracket-rendered") {
clearBracketFrameTimeout(state.bracket);
state.bracket.failed = false;
state.bracket.lastError = "";
syncBracketFallbackVisibility();
logDebug("bracket", "Bracket rendered successfully.");
return;
}
if (data.type === "ata:bracket-error") {
clearBracketFrameTimeout(state.bracket);
state.bracket.failed = true;
state.bracket.lastError = normalizeText(data.message || "Unbekannter Fehler");
syncBracketFallbackVisibility();
setNotice("error", `Turnierbaum-Fehler: ${state.bracket.lastError}. Fallback aktiv.`, 3600);
logWarn("bracket", "Bracket render error.", data);
}
}
// App layer: runtime orchestration, persistence scheduling and user feedback.
function removeMatchReturnShortcut() {
if (state.matchReturnShortcut.root instanceof HTMLElement) {
state.matchReturnShortcut.root.remove();
}
state.matchReturnShortcut.root = null;
state.matchReturnShortcut.syncing = false;
state.matchReturnShortcut.inlineSyncingByLobby = {};
state.matchReturnShortcut.inlineOutcomeByLobby = {};
}
function renderMatchReturnShortcut() {
removeMatchReturnShortcut();
}
function cleanupRuntime() {
if (state.saveTimer) {
clearTimeout(state.saveTimer);
state.saveTimer = null;
}
if (state.noticeTimer) {
clearTimeout(state.noticeTimer);
state.noticeTimer = null;
}
clearBracketFrameTimeout(state.bracket);
removeMatchReturnShortcut();
removeHistoryImportButton();
while (state.cleanupStack.length) {
const cleanup = state.cleanupStack.pop();
try {
cleanup();
} catch (error) {
logWarn("lifecycle", "Cleanup function failed.", error);
}
}
}
function initEventBridge() {
addListener(window, TOGGLE_EVENT, () => {
toggleDrawer();
});
addListener(window, "message", handleBracketMessage);
addListener(window, "pagehide", cleanupRuntime, { once: true });
addListener(window, "beforeunload", cleanupRuntime, { once: true });
}
// App layer: update-status orchestration for UI refresh and loader menu hint.
function getUpdateStatusSignature(updateStatus) {
return JSON.stringify({
capable: Boolean(updateStatus?.capable),
status: normalizeText(updateStatus?.status || ""),
installedVersion: normalizeText(updateStatus?.installedVersion || ""),
remoteVersion: normalizeText(updateStatus?.remoteVersion || ""),
available: Boolean(updateStatus?.available),
checkedAt: Number(updateStatus?.checkedAt || 0),
sourceUrl: normalizeText(updateStatus?.sourceUrl || ""),
error: normalizeText(updateStatus?.error || ""),
stale: Boolean(updateStatus?.stale),
});
}
function syncLoaderMenuUpdateIndicator() {
const button = document.getElementById(LOADER_MENU_ITEM_ID);
if (!(button instanceof HTMLElement)) {
return;
}
const hasUpdate = Boolean(state.updateStatus?.available);
const remoteVersion = normalizeText(state.updateStatus?.remoteVersion || "");
const title = hasUpdate && remoteVersion
? `xLokales Turnier - Update verfügbar (${APP_VERSION} -> ${remoteVersion})`
: "xLokales Turnier";
button.setAttribute("data-update-state", normalizeText(state.updateStatus?.status || "idle"));
button.setAttribute("title", title);
button.setAttribute("aria-label", title);
let dot = button.querySelector("[data-ata-loader-update-dot='1']");
if (hasUpdate) {
if (!(dot instanceof HTMLElement)) {
dot = document.createElement("span");
dot.setAttribute("data-ata-loader-update-dot", "1");
dot.setAttribute("aria-hidden", "true");
dot.style.position = "absolute";
dot.style.top = "0.42rem";
dot.style.right = "0.52rem";
dot.style.width = "0.58rem";
dot.style.height = "0.58rem";
dot.style.borderRadius = "999px";
dot.style.background = "#ff8370";
dot.style.boxShadow = "0 0 0 2px rgba(12,22,54,.92), 0 0 0 4px rgba(255,131,112,.18)";
dot.style.pointerEvents = "none";
if (!button.style.position) {
button.style.position = "relative";
}
button.appendChild(dot);
}
} else if (dot instanceof HTMLElement) {
dot.remove();
}
}
function setUpdateStatus(nextStatus = {}) {
const mergedStatus = {
...state.updateStatus,
...nextStatus,
installedVersion: APP_VERSION,
downloadUrl: USERSCRIPT_DOWNLOAD_URL,
};
const nextSignature = getUpdateStatusSignature(mergedStatus);
state.updateStatus = mergedStatus;
if (nextSignature === state.updateStatusSignature) {
syncLoaderMenuUpdateIndicator();
return;
}
state.updateStatusSignature = nextSignature;
syncLoaderMenuUpdateIndicator();
if (state.shadowRoot) {
renderShell();
}
}
function runSelfTests() {
const results = [];
const record = (name, ok, details = "") => {
results.push({ name, ok: Boolean(ok), details: normalizeText(details || "") });
};
const participantList = (count, prefix = "P") => {
const list = [];
for (let i = 1; i <= count; i += 1) {
list.push({ id: `${prefix}${i}`, name: `${prefix}${i}` });
}
return list;
};
try {
const participants = participantList(9, "S");
const ids = participants.map((item) => item.id);
const seededMatches = buildKoMatchesV2(ids, KO_DRAW_MODE_SEEDED);
const seededRoundOne = seededMatches.filter((match) => match.round === 1);
const seededOpenRoundOne = seededRoundOne.filter((match) => match.player1Id && match.player2Id && !isByeMatchResult(match));
record(
"KO Seeded: 9 Teilnehmer -> genau 1 offenes R1-Match",
seededOpenRoundOne.length === 1,
`offene R1-Matches: ${seededOpenRoundOne.length}`,
);
} catch (error) {
record("KO Seeded: 9 Teilnehmer -> genau 1 offenes R1-Match", false, String(error?.message || error));
}
try {
const participants = participantList(9, "O");
const ids = participants.map((item) => item.id);
const openDrawMatches = buildKoMatchesV2(ids, KO_DRAW_MODE_OPEN_DRAW);
const repeatedOpenDrawMatches = buildKoMatchesV2(ids, KO_DRAW_MODE_OPEN_DRAW);
const toSignature = (matches) => matches
.map((match) => `${match.id}:${match.player1Id || "-"}:${match.player2Id || "-"}:${isByeMatchResult(match) ? "bye" : "match"}`)
.join("|");
const deterministic = toSignature(openDrawMatches) === toSignature(repeatedOpenDrawMatches);
const byeCount = openDrawMatches.filter((match) => isByeMatchResult(match)).length;
record(
"KO Open Draw: deterministisch mit expliziten Byes",
deterministic && byeCount > 0,
`matches=${openDrawMatches.length}, byes=${byeCount}, deterministic=${deterministic}`,
);
} catch (error) {
record("KO Open Draw: deterministisch mit expliziten Byes", false, String(error?.message || error));
}
try {
const participants = participantList(6, "K6");
const structure = buildBracketStructure(participants, generateSeeds(participants, KO_DRAW_MODE_SEEDED));
const matches = buildKoMatchesFromStructure(structure);
const expectedTotalMatches = structure.rounds.reduce((sum, roundDef) => sum + roundDef.virtualMatches.length, 0);
const byeCount = matches.filter((match) => isByeMatchResult(match)).length;
record(
"KO 6: vollständiger 8er-Baum mit 2 Byes",
matches.length === expectedTotalMatches && expectedTotalMatches === 7 && byeCount === 2,
`matches=${matches.length}, expected=${expectedTotalMatches}, byes=${byeCount}`,
);
} catch (error) {
record("KO 6: vollständiger 8er-Baum mit 2 Byes", false, String(error?.message || error));
}
try {
const participants = participantList(8, "K8");
const structure = buildBracketStructure(participants, generateSeeds(participants, KO_DRAW_MODE_SEEDED));
const matches = buildKoMatchesFromStructure(structure);
const byeCount = matches.filter((match) => isByeMatchResult(match)).length;
record(
"KO 8: 7 Match-Knoten von Start an vorhanden",
matches.length === 7 && byeCount === 0,
`matches=${matches.length}, byes=${byeCount}`,
);
} catch (error) {
record("KO 8: 7 Match-Knoten von Start an vorhanden", false, String(error?.message || error));
}
try {
const presetChecks = validateCreatePresetDefinitions();
const europeanTourPreset = getCreatePresetDefinition(X01_PRESET_PDC_EUROPEAN_TOUR_OFFICIAL);
record(
"Preset-Schema: European Tour + Basic vollständig validiert",
presetChecks.every((entry) => entry.ok)
&& europeanTourPreset?.apply?.bestOfLegs === 11
&& europeanTourPreset?.apply?.startScore === 501,
presetChecks.map((entry) => `${entry.id}:${entry.ok ? "ok" : entry.issues.join("/")}`).join(", "),
);
} catch (error) {
record("Preset-Schema: European Tour + Basic vollständig validiert", false, String(error?.message || error));
}
try {
const compliant = isEuropeanTourOfficialMatchSetup({
mode: "ko",
bestOfLegs: 11,
startScore: 501,
x01InMode: "Straight",
x01OutMode: "Double",
x01BullMode: "25/50",
x01MaxRounds: 50,
x01BullOffMode: "Normal",
lobbyVisibility: "private",
});
const wrongBestOf = isEuropeanTourOfficialMatchSetup({
mode: "ko",
bestOfLegs: 5,
startScore: 501,
x01InMode: "Straight",
x01OutMode: "Double",
x01BullMode: "25/50",
x01MaxRounds: 50,
x01BullOffMode: "Normal",
lobbyVisibility: "private",
});
record(
"Preset-Setup: European Tour Official erfordert KO + Best of 11 + 501/SI/DO",
compliant && !wrongBestOf,
`official=${compliant}, wrongBestOf=${wrongBestOf}`,
);
} catch (error) {
record("Preset-Setup: European Tour Official erfordert KO + Best of 11 + 501/SI/DO", false, String(error?.message || error));
}
{
const previousTournament = state.store.tournament;
const previousDraft = cloneSerializable(state.store.ui?.createDraft);
try {
state.store.tournament = null;
state.store.ui.createDraft = createDefaultCreateDraft(state.store.settings);
renderShell();
const createForm = state.shadowRoot?.getElementById("ata-create-form");
const presetSelect = createForm?.querySelector("#ata-preset-select");
if (!(createForm instanceof HTMLFormElement) || !(presetSelect instanceof HTMLSelectElement)) {
throw new Error("Create form or preset select missing.");
}
presetSelect.value = X01_PRESET_PDC_EUROPEAN_TOUR_OFFICIAL;
applySelectedPresetToCreateForm(createForm);
const europeanTourDraft = normalizeCreateDraft(readCreateDraftInput(new FormData(createForm)), state.store.settings);
presetSelect.value = X01_PRESET_PDC_501_DOUBLE_OUT_BASIC;
applySelectedPresetToCreateForm(createForm);
const basicDraft = normalizeCreateDraft(readCreateDraftInput(new FormData(createForm)), state.store.settings);
record(
"Preset-UI: Auswahl + Anwenden setzt alle Formularfelder konsistent",
europeanTourDraft.x01Preset === X01_PRESET_PDC_EUROPEAN_TOUR_OFFICIAL
&& europeanTourDraft.mode === "ko"
&& europeanTourDraft.bestOfLegs === 11
&& basicDraft.x01Preset === X01_PRESET_PDC_501_DOUBLE_OUT_BASIC
&& basicDraft.bestOfLegs === 5
&& basicDraft.startScore === 501
&& basicDraft.x01OutMode === "Double",
`et=${europeanTourDraft.bestOfLegs}/${europeanTourDraft.x01Preset}, basic=${basicDraft.bestOfLegs}/${basicDraft.x01Preset}`,
);
} catch (error) {
record("Preset-UI: Auswahl + Anwenden setzt alle Formularfelder konsistent", false, String(error?.message || error));
} finally {
state.store.tournament = previousTournament;
state.store.ui.createDraft = previousDraft || createDefaultCreateDraft(state.store.settings);
renderShell();
}
}
try {
const tournament = createTournament({
name: "PayloadMapping",
mode: "league",
bestOfLegs: 7,
startScore: 701,
x01Preset: X01_PRESET_CUSTOM,
x01InMode: "Double",
x01OutMode: "Master",
x01BullMode: "50/50",
x01MaxRounds: 20,
x01BullOffMode: "Official",
lobbyVisibility: "private",
randomizeKoRound1: false,
participants: participantList(2, "PM"),
});
const payload = buildLobbyCreatePayload(tournament);
record(
"Turnieranlage -> Matchstart-Payload übernimmt X01 + Best-of konsistent",
payload?.variant === X01_VARIANT
&& payload?.isPrivate === true
&& payload?.bullOffMode === "Official"
&& payload?.legs === 4
&& payload?.settings?.baseScore === 701
&& payload?.settings?.inMode === "Double"
&& payload?.settings?.outMode === "Master"
&& payload?.settings?.maxRounds === 20
&& payload?.settings?.bullMode === "50/50",
`legs=${payload?.legs}, settings=${JSON.stringify(payload?.settings || {})}`,
);
} catch (error) {
record("Turnieranlage -> Matchstart-Payload übernimmt X01 + Best-of konsistent", false, String(error?.message || error));
}
try {
const tournament = createTournament({
name: "BullOffOff",
mode: "league",
bestOfLegs: 5,
startScore: 501,
x01Preset: X01_PRESET_CUSTOM,
x01InMode: "Straight",
x01OutMode: "Double",
x01BullMode: "50/50",
x01MaxRounds: 50,
x01BullOffMode: "Off",
lobbyVisibility: "private",
randomizeKoRound1: false,
participants: participantList(2, "BO"),
});
const payload = buildLobbyCreatePayload(tournament);
const hasBullMode = Object.prototype.hasOwnProperty.call(payload?.settings || {}, "bullMode");
record(
"Bull-off Off: Matchstart-Payload setzt top-level bullOffMode + bullMode",
payload?.bullOffMode === "Off"
&& hasBullMode,
`bullOffMode=${payload?.bullOffMode || "-"}, hasBullMode=${hasBullMode}`,
);
} catch (error) {
record("Bull-off Off: Matchstart-Payload setzt top-level bullOffMode + bullMode", false, String(error?.message || error));
}
try {
const retry = shouldRetryLobbyCreateWithBullModeFallback(
{ status: 400, message: "bull mode validation failed" },
{ settings: { bullMode: "50/50" } },
);
const noRetry = shouldRetryLobbyCreateWithBullModeFallback(
{ status: 400, message: "different validation failed" },
{ settings: { bullMode: "50/50" } },
);
record(
"Matchstart-Helfer: bullMode-Fallback wird nur bei passendem 400er aktiviert",
retry === true && noRetry === false,
`retry=${retry}, noRetry=${noRetry}`,
);
} catch (error) {
record("Matchstart-Helfer: bullMode-Fallback wird nur bei passendem 400er aktiviert", false, String(error?.message || error));
}
try {
const cleanupBeforeStart = shouldCleanupFailedMatchStartLobby("lobby-debug-1", false);
const cleanupAfterStartRequest = shouldCleanupFailedMatchStartLobby("lobby-debug-1", true);
record(
"Matchstart-Helfer: Lobby-Cleanup nur vor Start-Request",
cleanupBeforeStart === true && cleanupAfterStartRequest === false,
`before=${cleanupBeforeStart}, after=${cleanupAfterStartRequest}`,
);
} catch (error) {
record("Matchstart-Helfer: Lobby-Cleanup nur vor Start-Request", false, String(error?.message || error));
}
try {
const store = createDefaultStore();
recordMatchStartDebugSession(store, finalizeMatchStartDebugSession(
createMatchStartDebugSession({
tournamentId: "dbg-t-1",
matchId: "dbg-m-1",
}),
"success",
{
lobbyId: "dbg-lobby-1",
summary: { reasonCode: "started", message: "ok" },
},
));
const report = buildMatchStartDebugReport(store, { limit: 3 });
record(
"Debug-Report: Runtime API-Daten sind strukturiert und begrenzt",
report?.sessionCount === 1
&& Array.isArray(report?.sessions)
&& report.sessions[0]?.matchId === "dbg-m-1"
&& report.sessions[0]?.lobbyId === "dbg-lobby-1",
`count=${report?.sessionCount || 0}, first=${report?.sessions?.[0]?.matchId || "-"}`,
);
} catch (error) {
record("Debug-Report: Runtime API-Daten sind strukturiert und begrenzt", false, String(error?.message || error));
}
try {
const tournament = createTournament({
name: "DrawLockOn",
mode: "ko",
bestOfLegs: 3,
startScore: 501,
x01Preset: X01_PRESET_CUSTOM,
x01InMode: "Straight",
x01OutMode: "Double",
x01BullMode: "25/50",
x01MaxRounds: 50,
x01BullOffMode: "Normal",
lobbyVisibility: "private",
randomizeKoRound1: false,
koDrawLocked: true,
participants: participantList(8, "DL"),
});
const before = JSON.stringify(tournament.ko?.rounds || []);
tournament.participants = tournament.participants.slice().reverse();
refreshDerivedMatches(tournament);
const after = JSON.stringify(tournament.ko?.rounds || []);
record(
"Draw-Lock aktiv: KO-Struktur bleibt stabil",
before === after,
`stable=${before === after}`,
);
} catch (error) {
record("Draw-Lock aktiv: KO-Struktur bleibt stabil", false, String(error?.message || error));
}
try {
const tournament = createTournament({
name: "DrawLockOverride",
mode: "ko",
bestOfLegs: 3,
startScore: 501,
x01Preset: X01_PRESET_CUSTOM,
x01InMode: "Straight",
x01OutMode: "Double",
x01BullMode: "25/50",
x01MaxRounds: 50,
x01BullOffMode: "Normal",
lobbyVisibility: "private",
randomizeKoRound1: false,
koDrawLocked: true,
participants: participantList(8, "DO"),
});
const blocked = applyTournamentKoDrawLocked(tournament, false);
const confirmed = applyTournamentKoDrawLocked(tournament, false, { allowUnlockOverride: true });
record(
"Draw-Lock: Entsperren erfordert Override, mit Override erlaubt",
blocked?.ok === false
&& blocked?.reasonCode === "draw_unlock_requires_override"
&& Boolean(confirmed?.ok && confirmed?.changed)
&& tournament?.ko?.drawLocked === false,
`blocked=${blocked?.reasonCode || "-"}, confirmed=${Boolean(confirmed?.ok)}`,
);
} catch (error) {
record("Draw-Lock: Entsperren erfordert Override, mit Override erlaubt", false, String(error?.message || error));
}
try {
const tournament = createTournament({
name: "DrawLockOff",
mode: "ko",
bestOfLegs: 3,
startScore: 501,
x01Preset: X01_PRESET_CUSTOM,
x01InMode: "Straight",
x01OutMode: "Double",
x01BullMode: "25/50",
x01MaxRounds: 50,
x01BullOffMode: "Normal",
lobbyVisibility: "private",
randomizeKoRound1: false,
koDrawLocked: false,
participants: participantList(8, "DU"),
});
const before = JSON.stringify(tournament.ko?.rounds || []);
tournament.participants = tournament.participants.slice().reverse();
refreshDerivedMatches(tournament);
const after = JSON.stringify(tournament.ko?.rounds || []);
record(
"Draw-Lock aus: KO-Struktur kann neu aufgebaut werden",
before !== after,
`changed=${before !== after}`,
);
} catch (error) {
record("Draw-Lock aus: KO-Struktur kann neu aufgebaut werden", false, String(error?.message || error));
}
try {
const matches = [
createMatch({ id: "m-ab", stage: MATCH_STAGE_LEAGUE, round: 1, number: 1, player1Id: "A", player2Id: "B", status: STATUS_COMPLETED, winnerId: "A", legs: { p1: 2, p2: 1 } }),
createMatch({ id: "m-ac", stage: MATCH_STAGE_LEAGUE, round: 1, number: 2, player1Id: "A", player2Id: "C", status: STATUS_COMPLETED, winnerId: "A", legs: { p1: 2, p2: 1 } }),
createMatch({ id: "m-ad", stage: MATCH_STAGE_LEAGUE, round: 1, number: 3, player1Id: "A", player2Id: "D", status: STATUS_COMPLETED, winnerId: "D", legs: { p1: 0, p2: 2 } }),
createMatch({ id: "m-bc", stage: MATCH_STAGE_LEAGUE, round: 2, number: 1, player1Id: "B", player2Id: "C", status: STATUS_COMPLETED, winnerId: "B", legs: { p1: 2, p2: 0 } }),
createMatch({ id: "m-bd", stage: MATCH_STAGE_LEAGUE, round: 2, number: 2, player1Id: "B", player2Id: "D", status: STATUS_COMPLETED, winnerId: "B", legs: { p1: 2, p2: 0 } }),
createMatch({ id: "m-cd", stage: MATCH_STAGE_LEAGUE, round: 2, number: 3, player1Id: "C", player2Id: "D", status: STATUS_COMPLETED, winnerId: "C", legs: { p1: 2, p2: 1 } }),
];
const h2hTournament = {
id: "tb1",
name: "TB1",
mode: "league",
ko: null,
bestOfLegs: 3,
startScore: 501,
x01: buildPresetX01Settings(X01_PRESET_PDC_501_DOUBLE_OUT_BASIC),
rules: normalizeTournamentRules({ tieBreakProfile: TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE }),
participants: [
{ id: "A", name: "A" },
{ id: "B", name: "B" },
{ id: "C", name: "C" },
{ id: "D", name: "D" },
],
groups: [],
matches: cloneSerializable(matches),
createdAt: nowIso(),
updatedAt: nowIso(),
};
const pointsLegDiffTournament = {
...h2hTournament,
id: "tb2",
rules: normalizeTournamentRules({ tieBreakProfile: TIE_BREAK_PROFILE_PROMOTER_POINTS_LEGDIFF }),
matches: cloneSerializable(matches),
};
const h2hRows = standingsForMatches(h2hTournament, h2hTournament.matches);
const legacyRows = standingsForMatches(pointsLegDiffTournament, pointsLegDiffTournament.matches);
record(
"Tie-Break-Profile: H2H und Punkte+LegDiff liefern unterschiedliche Reihenfolge",
h2hRows[0]?.id === "A" && legacyRows[0]?.id === "B",
`h2h=${h2hRows[0]?.id || "-"}, legacy=${legacyRows[0]?.id || "-"}`,
);
} catch (error) {
record("Tie-Break-Profile: H2H und Punkte+LegDiff liefern unterschiedliche Reihenfolge", false, String(error?.message || error));
}
try {
const tournament = createTournament({
name: "TieBreakLocked",
mode: "league",
bestOfLegs: 3,
startScore: 501,
x01Preset: X01_PRESET_CUSTOM,
x01InMode: "Straight",
x01OutMode: "Double",
x01BullMode: "25/50",
x01MaxRounds: 50,
x01BullOffMode: "Normal",
lobbyVisibility: "private",
randomizeKoRound1: false,
participants: participantList(4, "TL"),
});
const firstLeagueMatch = tournament.matches.find((match) => match.stage === MATCH_STAGE_LEAGUE);
firstLeagueMatch.status = STATUS_COMPLETED;
firstLeagueMatch.winnerId = firstLeagueMatch.player1Id;
firstLeagueMatch.legs = { p1: 2, p2: 0 };
const result = applyTournamentTieBreakProfile(tournament, TIE_BREAK_PROFILE_PROMOTER_POINTS_LEGDIFF);
record(
"Tie-Break-Profil: nach erstem Ergebnis gesperrt",
result?.ok === false
&& result?.reasonCode === "tie_break_locked"
&& tournament.rules.tieBreakProfile === TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE,
`ok=${Boolean(result?.ok)}, reason=${result?.reasonCode || "-"}`,
);
} catch (error) {
record("Tie-Break-Profil: nach erstem Ergebnis gesperrt", false, String(error?.message || error));
}
try {
const tournament = createTournament({
name: "GroupsKo",
mode: "groups_ko",
bestOfLegs: 3,
startScore: 501,
x01Preset: X01_PRESET_CUSTOM,
x01InMode: "Straight",
x01OutMode: "Double",
x01BullMode: "25/50",
x01MaxRounds: 50,
x01BullOffMode: "Normal",
lobbyVisibility: "private",
randomizeKoRound1: false,
participants: participantList(4, "G"),
});
const groupA = findMatch(tournament, "group-A-r1-m1");
const groupB = findMatch(tournament, "group-B-r1-m1");
groupA.status = STATUS_COMPLETED;
groupA.winnerId = groupA.player1Id;
groupA.legs = { p1: 2, p2: 0 };
groupB.status = STATUS_COMPLETED;
groupB.winnerId = groupB.player1Id;
groupB.legs = { p1: 2, p2: 1 };
refreshDerivedMatches(tournament);
const semi1 = findMatch(tournament, "ko-r1-m1");
const semi2 = findMatch(tournament, "ko-r1-m2");
semi1.status = STATUS_COMPLETED;
semi1.winnerId = semi1.player1Id;
semi1.legs = { p1: 2, p2: 0 };
semi2.status = STATUS_COMPLETED;
semi2.winnerId = semi2.player1Id;
semi2.legs = { p1: 2, p2: 1 };
refreshDerivedMatches(tournament);
const final = findMatch(tournament, "ko-r2-m1");
record(
"Groups+KO Regression: Finale wird korrekt aus Semis belegt",
Boolean(final?.player1Id && final?.player2Id),
`final=${final?.player1Id || "-"}:${final?.player2Id || "-"}`,
);
} catch (error) {
record("Groups+KO Regression: Finale wird korrekt aus Semis belegt", false, String(error?.message || error));
}
try {
const tournament = createTournament({
name: "GroupsKoTieBreakLock",
mode: "groups_ko",
bestOfLegs: 3,
startScore: 501,
x01Preset: X01_PRESET_CUSTOM,
x01InMode: "Straight",
x01OutMode: "Double",
x01BullMode: "25/50",
x01MaxRounds: 50,
x01BullOffMode: "Normal",
lobbyVisibility: "private",
randomizeKoRound1: false,
participants: participantList(4, "GL"),
});
const firstGroupMatch = tournament.matches.find((match) => match.stage === MATCH_STAGE_GROUP);
firstGroupMatch.status = STATUS_COMPLETED;
firstGroupMatch.winnerId = firstGroupMatch.player1Id;
firstGroupMatch.legs = { p1: 2, p2: 0 };
refreshDerivedMatches(tournament);
const beforeSemiState = JSON.stringify([
findMatch(tournament, "ko-r1-m1")?.player1Id || null,
findMatch(tournament, "ko-r1-m1")?.player2Id || null,
findMatch(tournament, "ko-r1-m2")?.player1Id || null,
findMatch(tournament, "ko-r1-m2")?.player2Id || null,
]);
const blocked = applyTournamentTieBreakProfile(tournament, TIE_BREAK_PROFILE_PROMOTER_POINTS_LEGDIFF);
refreshDerivedMatches(tournament);
const afterSemiState = JSON.stringify([
findMatch(tournament, "ko-r1-m1")?.player1Id || null,
findMatch(tournament, "ko-r1-m1")?.player2Id || null,
findMatch(tournament, "ko-r1-m2")?.player1Id || null,
findMatch(tournament, "ko-r1-m2")?.player2Id || null,
]);
record(
"Groups+KO: Tie-Break-Lock verhindert nachtraegliche KO-Neuzuordnung",
blocked?.ok === false
&& blocked?.reasonCode === "tie_break_locked"
&& beforeSemiState === afterSemiState,
`reason=${blocked?.reasonCode || "-"}, stable=${beforeSemiState === afterSemiState}`,
);
} catch (error) {
record("Groups+KO: Tie-Break-Lock verhindert nachtraegliche KO-Neuzuordnung", false, String(error?.message || error));
}
try {
const tournament = {
id: "t2",
name: "T2",
mode: "groups_ko",
ko: null,
bestOfLegs: 3,
startScore: 501,
x01: buildPresetX01Settings(X01_PRESET_PDC_501_DOUBLE_OUT_BASIC),
rules: normalizeTournamentRules({ tieBreakProfile: TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE }),
participants: [
{ id: "A", name: "A" },
{ id: "B", name: "B" },
{ id: "C", name: "C" },
],
groups: [],
matches: [
createMatch({ id: "m1", stage: MATCH_STAGE_GROUP, groupId: "A", round: 1, number: 1, player1Id: "A", player2Id: "B", status: STATUS_COMPLETED, winnerId: "A", legs: { p1: 2, p2: 1 } }),
createMatch({ id: "m2", stage: MATCH_STAGE_GROUP, groupId: "A", round: 2, number: 1, player1Id: "B", player2Id: "C", status: STATUS_COMPLETED, winnerId: "B", legs: { p1: 2, p2: 1 } }),
createMatch({ id: "m3", stage: MATCH_STAGE_GROUP, groupId: "A", round: 3, number: 1, player1Id: "C", player2Id: "A", status: STATUS_COMPLETED, winnerId: "C", legs: { p1: 2, p2: 1 } }),
],
createdAt: nowIso(),
updatedAt: nowIso(),
};
const rows = standingsForMatches(tournament, tournament.matches, ["A", "B", "C"]);
const blocked = rows.filter((row) => row.tiebreakState === "playoff_required").length;
record(
"Promoter H2H: Deadlock -> Playoff erforderlich",
blocked === 3,
rows.map((row) => `${row.id}:${row.tiebreakState}`).join(", "),
);
} catch (error) {
record("Promoter H2H: Deadlock -> Playoff erforderlich", false, String(error?.message || error));
}
try {
const tournament = {
participants: [
{ id: "P1", name: "Sabine" },
{ id: "P2", name: "Tanja" },
],
};
const match = {
player1Id: "P1",
player2Id: "P2",
};
const apiStats = {
winner: 1,
players: [
{ name: "Sabine" },
{ name: "Tanja" },
],
matchStats: [
{ legsWon: 1 },
{ legsWon: 0 },
],
};
const candidates = getApiMatchLegCandidatesFromStats(tournament, match, apiStats, "P2");
const best = candidates[0] || { p1: -1, p2: -1 };
record(
"API Sync: vertauschte Legs-Reihenfolge wird korrigiert",
best.p1 === 0 && best.p2 === 1,
`best=${best.p1}:${best.p2}`,
);
} catch (error) {
record("API Sync: vertauschte Legs-Reihenfolge wird korrigiert", false, String(error?.message || error));
}
try {
const tournament = {
participants: [
{ id: "P1", name: "Sabine" },
{ id: "P2", name: "Tanja" },
],
};
const match = {
player1Id: "P1",
player2Id: "P2",
};
const apiStats = {
winner: 0,
players: [
{ name: "Sabine" },
{ name: "Tanja" },
],
matchStats: [
{ legsWon: 1, player: { name: "Tanja" } },
{ legsWon: 0, player: { name: "Sabine" } },
],
};
const winners = resolveWinnerIdCandidatesFromApiStats(tournament, match, apiStats, 0);
record(
"API Sync: Winner-Index aus matchStats wird bevorzugt",
winners[0] === "P2",
`first=${winners[0] || "-"}`,
);
} catch (error) {
record("API Sync: Winner-Index aus matchStats wird bevorzugt", false, String(error?.message || error));
}
try {
const tournament = {
participants: [
{ id: "P1", name: "Tommy" },
{ id: "P2", name: "Hans" },
],
matches: [
createMatch({ id: "m1", stage: MATCH_STAGE_GROUP, round: 1, number: 1, player1Id: "P1", player2Id: "P2", status: STATUS_PENDING }),
createMatch({ id: "m2", stage: MATCH_STAGE_KO, round: 2, number: 1, player1Id: "P1", player2Id: "P2", status: STATUS_PENDING }),
],
};
const apiStats = {
players: [
{ name: "Tommy" },
{ name: "Hans" },
],
matchStats: [
{ player: { name: "Tommy" }, legsWon: 1 },
{ player: { name: "Hans" }, legsWon: 0 },
],
};
const recovered = findOpenMatchCandidatesByApiStats(tournament, apiStats);
record(
"API Sync: Recovery erkennt mehrdeutige Match-Zuordnung",
recovered.length === 2,
`candidates=${recovered.length}`,
);
} catch (error) {
record("API Sync: Recovery erkennt mehrdeutige Match-Zuordnung", false, String(error?.message || error));
}
try {
record(
"Auto-Detect: Route-Guard nur fuer /matches/{id} und /lobbies/{id}",
isAutoDetectMatchRoute("/matches/abc123")
&& isAutoDetectMatchRoute("/lobbies/abc123")
&& !isAutoDetectMatchRoute("/history/matches/abc123")
&& !isAutoDetectMatchRoute("/settings"),
`match=${isAutoDetectMatchRoute("/matches/abc123")}, lobby=${isAutoDetectMatchRoute("/lobbies/abc123")}, history=${isAutoDetectMatchRoute("/history/matches/abc123")}`,
);
} catch (error) {
record("Auto-Detect: Route-Guard nur fuer /matches/{id} und /lobbies/{id}", false, String(error?.message || error));
}
try {
const tournament = {
participants: [
{ id: "P1", name: "Tanja Mueller" },
{ id: "P2", name: "Simon Stark" },
],
};
const ids = participantIdsByName(tournament, "TANJA");
record(
"History Import: Namens-Matching erkennt Teilnamen",
ids.includes("P1"),
`ids=${ids.join(",")}`,
);
} catch (error) {
record("History Import: Namens-Matching erkennt Teilnamen", false, String(error?.message || error));
}
{
const previousTournament = state.store.tournament;
try {
const tournament = {
id: "history-test-lobby",
name: "History",
mode: "league",
ko: null,
bestOfLegs: 3,
startScore: 501,
x01: buildPresetX01Settings(X01_PRESET_PDC_501_DOUBLE_OUT_BASIC),
rules: normalizeTournamentRules({ tieBreakProfile: TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE }),
participants: [
{ id: "P1", name: "Tanja Mueller" },
{ id: "P2", name: "Simon Stark" },
],
groups: [],
matches: [
createMatch({
id: "m-history-lobby",
stage: MATCH_STAGE_LEAGUE,
round: 1,
number: 1,
player1Id: "P1",
player2Id: "P2",
meta: {
auto: {
lobbyId: "lobby-history-1",
status: "started",
},
},
}),
],
createdAt: nowIso(),
updatedAt: nowIso(),
};
state.store.tournament = tournament;
const table = document.createElement("table");
table.innerHTML = `
Stats
TANJA
SIMON
Gewonnene Legs 1 0
`;
const outcome = importHistoryStatsTableResult("lobby-history-1", { table, reasonCode: "ok" });
const confirmationSignature = normalizeText(outcome?.confirm?.signature || "");
const confirmed = importHistoryStatsTableResult("lobby-history-1", {
table,
reasonCode: "ok",
}, {
confirmationSignature,
});
const updated = findMatch(tournament, "m-history-lobby");
record(
"History Import: Legs-Abweichung fordert Bestätigung und speichert danach",
outcome?.reasonCode === "requires_confirmation"
&& Boolean(confirmationSignature)
&& confirmed?.reasonCode === "completed"
&& updated?.status === STATUS_COMPLETED
&& updated?.winnerId === "P1"
&& updated?.legs?.p1 === 2
&& updated?.legs?.p2 === 0,
`first=${outcome?.reasonCode || "-"}, second=${confirmed?.reasonCode || "-"}, winner=${updated?.winnerId || "-"}, legs=${updated?.legs?.p1}:${updated?.legs?.p2}`,
);
} catch (error) {
record("History Import: Legs-Abweichung fordert Bestätigung und speichert danach", false, String(error?.message || error));
} finally {
state.store.tournament = previousTournament;
}
}
{
const previousTournament = state.store.tournament;
try {
const tournament = {
id: "history-test-confirm-invalid",
name: "History",
mode: "league",
ko: null,
bestOfLegs: 3,
startScore: 501,
x01: buildPresetX01Settings(X01_PRESET_PDC_501_DOUBLE_OUT_BASIC),
rules: normalizeTournamentRules({ tieBreakProfile: TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE }),
participants: [
{ id: "P1", name: "Alex" },
{ id: "P2", name: "Ben" },
],
groups: [],
matches: [
createMatch({
id: "m-history-confirm-invalid",
stage: MATCH_STAGE_LEAGUE,
round: 1,
number: 1,
player1Id: "P1",
player2Id: "P2",
meta: {
auto: {
lobbyId: "lobby-history-confirm-invalid",
status: "started",
},
},
}),
],
createdAt: nowIso(),
updatedAt: nowIso(),
};
state.store.tournament = tournament;
const table = document.createElement("table");
table.innerHTML = `
Stats
ALEX
BEN
Gewonnene Legs 1 0
`;
const needsConfirm = importHistoryStatsTableResult("lobby-history-confirm-invalid", { table, reasonCode: "ok" });
const invalidConfirm = importHistoryStatsTableResult("lobby-history-confirm-invalid", {
table,
reasonCode: "ok",
}, {
confirmationSignature: "invalid-signature",
});
const pendingMap = state.matchReturnShortcut.pendingConfirmationByLobby || {};
const pending = pendingMap["lobby-history-confirm-invalid"];
if (pending) {
pending.expiresAt = Date.now() - 1000;
}
const expiredConfirm = importHistoryStatsTableResult("lobby-history-confirm-invalid", {
table,
reasonCode: "ok",
}, {
confirmationSignature: normalizeText(needsConfirm?.confirm?.signature || ""),
});
record(
"History Import: falsche oder abgelaufene Bestätigung wird abgelehnt",
needsConfirm?.reasonCode === "requires_confirmation"
&& invalidConfirm?.reasonCode === "confirmation_invalid"
&& expiredConfirm?.reasonCode === "confirmation_expired",
`first=${needsConfirm?.reasonCode || "-"}, invalid=${invalidConfirm?.reasonCode || "-"}, expired=${expiredConfirm?.reasonCode || "-"}`,
);
} catch (error) {
record("History Import: falsche oder abgelaufene Bestätigung wird abgelehnt", false, String(error?.message || error));
} finally {
state.store.tournament = previousTournament;
}
}
{
const previousTournament = state.store.tournament;
try {
const tournament = {
id: "history-test-ambiguous",
name: "History",
mode: "league",
ko: null,
bestOfLegs: 1,
startScore: 501,
x01: buildPresetX01Settings(X01_PRESET_PDC_501_DOUBLE_OUT_BASIC),
rules: normalizeTournamentRules({ tieBreakProfile: TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE }),
participants: [
{ id: "P1", name: "Tommy" },
{ id: "P2", name: "Hans" },
],
groups: [],
matches: [
createMatch({
id: "m-history-a",
stage: MATCH_STAGE_LEAGUE,
round: 1,
number: 1,
player1Id: "P1",
player2Id: "P2",
}),
createMatch({
id: "m-history-b",
stage: MATCH_STAGE_KO,
round: 2,
number: 1,
player1Id: "P1",
player2Id: "P2",
meta: {
auto: {
lobbyId: "lobby-history-2",
status: "started",
},
},
}),
],
createdAt: nowIso(),
updatedAt: nowIso(),
};
state.store.tournament = tournament;
const table = document.createElement("table");
table.innerHTML = `
Stats
TOMMY
HANS
Gewonnene Legs 1 0
`;
const outcome = importHistoryStatsTableResult("lobby-history-2", { table });
const matchA = findMatch(tournament, "m-history-a");
const matchB = findMatch(tournament, "m-history-b");
record(
"History Import: bei Mehrdeutigkeit gewinnt verknüpfte Lobby",
Boolean(outcome?.ok)
&& matchA?.status === STATUS_PENDING
&& matchB?.status === STATUS_COMPLETED
&& matchB?.winnerId === "P1",
`reason=${outcome?.reasonCode || "-"}, A=${matchA?.status || "-"}, B=${matchB?.status || "-"}:${matchB?.winnerId || "-"}`,
);
} catch (error) {
record("History Import: bei Mehrdeutigkeit gewinnt verknüpfte Lobby", false, String(error?.message || error));
} finally {
state.store.tournament = previousTournament;
}
}
try {
const hostSandbox = document.createElement("div");
hostSandbox.setAttribute("data-ata-selftest-host-sandbox", "1");
hostSandbox.innerHTML = `
`;
document.body.appendChild(hostSandbox);
const ambiguousHost = findHistoryImportHost("lobby-host-check");
const missingHost = findHistoryImportHost("lobby-no-link");
hostSandbox.remove();
record(
"History Import: Host-Erkennung lehnt mehrdeutige oder routenfremde Tabellen ab",
ambiguousHost?.reasonCode === "history_host_ambiguous"
&& missingHost?.reasonCode === "history_host_not_found",
`ambiguous=${ambiguousHost?.reasonCode || "-"}, missing=${missingHost?.reasonCode || "-"}`,
);
} catch (error) {
record("History Import: Host-Erkennung lehnt mehrdeutige oder routenfremde Tabellen ab", false, String(error?.message || error));
}
try {
const comparisonsOk = compareVersions("0.3.4", "0.3.3") > 0
&& compareVersions("0.3.3", "0.3.3") === 0
&& compareVersions("0.3.3-beta", "0.3.3") < 0;
const parsed = parseUserscriptVersion(`// @version ${APP_VERSION}\n`);
record(
"Update-Check: Versionsvergleich und Header-Parsing arbeiten konsistent",
comparisonsOk && parsed === APP_VERSION,
`parsed=${parsed}, gt=${compareVersions("0.3.4", "0.3.3")}`,
);
} catch (error) {
record("Update-Check: Versionsvergleich und Header-Parsing arbeiten konsistent", false, String(error?.message || error));
}
try {
const storageMap = {
[UPDATE_STATUS_STORAGE_KEY]: JSON.stringify({
remoteVersion: "9.9.9",
checkedAt: 1_770_301_234_567,
sourceUrl: USERSCRIPT_UPDATE_URL,
validators: {
[USERSCRIPT_UPDATE_URL]: {
remoteVersion: "9.9.9",
etag: "\"ata-update\"",
lastModified: "Tue, 02 Jan 2024 00:00:00 GMT",
},
},
}),
};
const fakeWindow = {
localStorage: {
getItem(key) {
return Object.prototype.hasOwnProperty.call(storageMap, key) ? storageMap[key] : null;
},
setItem(key, value) {
storageMap[key] = String(value);
},
},
fetch() {},
};
const status = readStoredUpdateStatus({
windowRef: fakeWindow,
installedVersion: APP_VERSION,
});
const resolved = createResolvedUpdateStatus({
capable: true,
installedVersion: APP_VERSION,
remoteVersion: "9.9.9",
checkedAt: 1_770_301_234_567,
sourceUrl: USERSCRIPT_UPDATE_URL,
validators: status.validators,
});
const requestUrl = new URL(buildCacheBustedUrl(USERSCRIPT_UPDATE_URL, 1_770_301_234_567));
record(
"Update-Check: gecachter Status und Cache-Bust-URL werden konsistent abgeleitet",
status.capable === true
&& resolved.available === true
&& resolved.remoteVersion === "9.9.9"
&& requestUrl?.searchParams?.get(UPDATE_CACHE_BUST_PARAM) === "1770301234567"
&& status.validators?.[USERSCRIPT_UPDATE_URL]?.etag === "\"ata-update\"",
`available=${resolved.available}, remote=${resolved.remoteVersion}, source=${status.sourceUrl || "-"}`,
);
} catch (error) {
record("Update-Check: gecachter Status und Cache-Bust-URL werden konsistent abgeleitet", false, String(error?.message || error));
}
try {
const bearer = extractAuthTokenFromAuthorizationHeader("Bearer test.token.value");
const plain = extractAuthTokenFromAuthorizationHeader("test.token.value");
const invalidBasic = extractAuthTokenFromAuthorizationHeader("Basic test.token.value");
const invalidOtherScheme = extractAuthTokenFromAuthorizationHeader("Token test.token.value");
record(
"API Auth: Authorization-Header-Parser akzeptiert Bearer/plain und lehnt fremde Schemes ab",
bearer === "test.token.value"
&& plain === "test.token.value"
&& invalidBasic === ""
&& invalidOtherScheme === "",
`bearer=${Boolean(bearer)}, plain=${Boolean(plain)}, basicRejected=${invalidBasic === ""}, otherRejected=${invalidOtherScheme === ""}`,
);
} catch (error) {
record("API Auth: Authorization-Header-Parser akzeptiert Bearer/plain und lehnt fremde Schemes ab", false, String(error?.message || error));
}
try {
const previousToken = state.apiAutomation.authToken;
const previousSource = state.apiAutomation.authTokenSource;
const previousExpiry = state.apiAutomation.authTokenExpiresAt;
try {
cacheResolvedAuthToken("", "");
const ignored = captureAuthTokenFromRequestHeaders(
{ Authorization: "Bearer ignored.token" },
{ requestUrl: "https://example.com/test", source: "selftest:ignored" },
);
const captured = captureAuthTokenFromRequestHeaders(
{ Authorization: "Bearer captured.token" },
{ requestUrl: `${API_GS_BASE}/lobbies`, source: "selftest:xhr" },
);
const snapshot = getAuthStateSnapshot();
record(
"API Auth: Header-Capture übernimmt nur api.autodarts.io und setzt Cache-Quelle",
ignored === ""
&& captured === "captured.token"
&& snapshot.hasCachedToken === true
&& state.apiAutomation.authTokenSource === "selftest:xhr",
`ignored=${Boolean(ignored)}, captured=${Boolean(captured)}, hasCache=${snapshot.hasCachedToken}, source=${state.apiAutomation.authTokenSource || "-"}`,
);
} finally {
state.apiAutomation.authToken = previousToken || "";
state.apiAutomation.authTokenSource = previousSource || "";
state.apiAutomation.authTokenExpiresAt = Number(previousExpiry || 0);
}
} catch (error) {
record("API Auth: Header-Capture übernimmt nur api.autodarts.io und setzt Cache-Quelle", false, String(error?.message || error));
}
try {
const previousToken = state.apiAutomation.authToken;
const previousSource = state.apiAutomation.authTokenSource;
const previousExpiry = state.apiAutomation.authTokenExpiresAt;
try {
cacheResolvedAuthToken("", "");
installRuntimeAuthHeaderCapture();
window.dispatchEvent(new CustomEvent("ata:auth-header-captured", {
detail: {
token: "bridge.token.capture",
source: "selftest:bridge",
requestUrl: `${API_GS_BASE}/lobbies`,
},
}));
const snapshot = getAuthStateSnapshot();
record(
"API Auth: Page-Bridge-Event wird als Runtime-Token übernommen",
snapshot.hasCachedToken === true
&& state.apiAutomation.authTokenSource === "selftest:bridge",
`hasCache=${snapshot.hasCachedToken}, source=${state.apiAutomation.authTokenSource || "-"}`,
);
} finally {
state.apiAutomation.authToken = previousToken || "";
state.apiAutomation.authTokenSource = previousSource || "";
state.apiAutomation.authTokenExpiresAt = Number(previousExpiry || 0);
}
} catch (error) {
record("API Auth: Page-Bridge-Event wird als Runtime-Token übernommen", false, String(error?.message || error));
}
try {
const previousRefreshToken = localStorage.getItem("autodarts_refresh_token");
const previousCachedToken = state.apiAutomation.authToken;
const previousCachedExpiry = state.apiAutomation.authTokenExpiresAt;
const previousCachedSource = state.apiAutomation.authTokenSource;
try {
localStorage.setItem("autodarts_refresh_token", "selftest-refresh-token");
cacheResolvedAuthToken("", "");
const snapshotWithRefresh = getAuthStateSnapshot();
cacheResolvedAuthToken("header.payload.signature", "selftest", Date.now() + 120000);
const snapshotWithCache = getAuthStateSnapshot();
record(
"API Auth: Snapshot erkennt Refresh-Token- und Cache-Kontext",
snapshotWithRefresh.hasRefreshToken === true
&& snapshotWithRefresh.hasAnyAuthContext === true
&& snapshotWithCache.hasCachedToken === true
&& snapshotWithCache.cachedTokenUsable === true,
`refresh=${snapshotWithRefresh.hasRefreshToken}, cache=${snapshotWithCache.hasCachedToken}, usable=${snapshotWithCache.cachedTokenUsable}`,
);
} finally {
if (previousRefreshToken) {
localStorage.setItem("autodarts_refresh_token", previousRefreshToken);
} else {
localStorage.removeItem("autodarts_refresh_token");
}
state.apiAutomation.authToken = previousCachedToken || "";
state.apiAutomation.authTokenExpiresAt = Number(previousCachedExpiry || 0);
state.apiAutomation.authTokenSource = previousCachedSource || "";
}
} catch (error) {
record("API Auth: Snapshot erkennt Refresh-Token- und Cache-Kontext", false, String(error?.message || error));
}
try {
const rawStoreV2 = {
schemaVersion: 2,
settings: { debug: false, featureFlags: { autoLobbyStart: false, randomizeKoRound1: true } },
ui: { activeTab: "tournament", matchesSortMode: MATCH_SORT_MODE_READY_FIRST },
tournament: {
id: "legacy",
name: "Legacy",
mode: "league",
bestOfLegs: 3,
startScore: 501,
x01: buildPresetX01Settings(X01_PRESET_PDC_501_DOUBLE_OUT_BASIC),
participants: [{ id: "A", name: "A" }, { id: "B", name: "B" }],
groups: [],
matches: [],
createdAt: nowIso(),
updatedAt: nowIso(),
},
};
const migrated = migrateStorage(rawStoreV2);
record(
"Migration: v2 -> v4 setzt Tie-Break-Profil",
migrated.schemaVersion === 4
&& migrated.tournament?.rules?.tieBreakProfile === TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE
&& migrated.settings?.tournamentTimeProfile === TOURNAMENT_TIME_PROFILE_NORMAL
&& migrated.settings?.featureFlags?.koDrawLockDefault === true,
`schema=${migrated.schemaVersion}, profile=${migrated.tournament?.rules?.tieBreakProfile}`,
);
} catch (error) {
record("Migration: v2 -> v4 setzt Tie-Break-Profil", false, String(error?.message || error));
}
const passed = results.filter((entry) => entry.ok).length;
const failed = results.length - passed;
return {
ok: failed === 0,
passed,
failed,
results,
generatedAt: nowIso(),
version: APP_VERSION,
};
}
// App layer: runtime orchestration, persistence scheduling and user feedback.
function setupRuntimeApi() {
window[RUNTIME_GLOBAL_KEY] = {
version: APP_VERSION,
isReady: () => state.ready,
openDrawer,
closeDrawer,
toggleDrawer,
getDebugReport: () => buildMatchStartDebugReport(state.store),
runSelfTests,
};
addCleanup(() => {
if (window[RUNTIME_GLOBAL_KEY]) {
delete window[RUNTIME_GLOBAL_KEY];
}
});
}
function shouldShowAuthNotice() {
const now = Date.now();
if (now - state.apiAutomation.lastAuthNoticeAt < API_AUTH_NOTICE_THROTTLE_MS) {
return false;
}
state.apiAutomation.lastAuthNoticeAt = now;
return true;
}
function getAuthTokenFromCookie() {
try {
const value = `; ${document.cookie || ""}`;
const parts = value.split("; Authorization=");
if (parts.length !== 2) {
return "";
}
let token = parts.pop().split(";").shift() || "";
try {
token = decodeURIComponent(token);
} catch (_) {
// Keep raw token if decoding fails.
}
token = String(token).trim().replace(/^Bearer\s+/i, "");
return token;
} catch (_) {
return "";
}
}
function decodeJwtPayload(token) {
const rawToken = normalizeText(token || "");
if (!rawToken) {
return null;
}
const parts = rawToken.split(".");
if (parts.length < 2) {
return null;
}
try {
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = base64 + "===".slice((base64.length + 3) % 4);
const json = atob(padded);
return JSON.parse(json);
} catch (_) {
return null;
}
}
function getTokenExpiryMs(token, fallbackExpiryMs = 0) {
const payload = decodeJwtPayload(token);
const expSeconds = Number(payload?.exp || 0);
if (Number.isFinite(expSeconds) && expSeconds > 0) {
return expSeconds * 1000;
}
return Number(fallbackExpiryMs || 0);
}
function cacheResolvedAuthToken(token, source = "", fallbackExpiryMs = 0) {
const normalizedToken = normalizeText(token || "");
state.apiAutomation.authToken = normalizedToken;
state.apiAutomation.authTokenSource = normalizeText(source || "");
state.apiAutomation.authTokenExpiresAt = normalizedToken
? getTokenExpiryMs(normalizedToken, fallbackExpiryMs)
: 0;
return normalizedToken;
}
function isCachedAuthTokenUsable() {
const token = normalizeText(state.apiAutomation.authToken || "");
if (!token) {
return false;
}
const expiresAt = Number(state.apiAutomation.authTokenExpiresAt || 0);
if (!Number.isFinite(expiresAt) || expiresAt <= 0) {
return true;
}
return Date.now() < (expiresAt - 30 * 1000);
}
function getRefreshTokenFromStorage() {
try {
return normalizeText(localStorage.getItem("autodarts_refresh_token") || "");
} catch (_) {
return "";
}
}
function extractAuthTokenFromAuthorizationHeader(value) {
const rawValue = normalizeText(value || "");
if (!rawValue) {
return "";
}
if (/^basic\s+/i.test(rawValue)) {
return "";
}
if (/^[a-z-]+\s+/i.test(rawValue) && !/^bearer\s+/i.test(rawValue)) {
return "";
}
return normalizeText(rawValue.replace(/^bearer\s+/i, ""));
}
function getHeaderValueCaseInsensitive(headers, name) {
const target = normalizeText(name || "").toLowerCase();
if (!target || !headers) {
return "";
}
if (typeof Headers !== "undefined" && headers instanceof Headers) {
return normalizeText(headers.get(target) || "");
}
if (Array.isArray(headers)) {
for (let index = 0; index < headers.length; index += 1) {
const entry = headers[index];
if (!Array.isArray(entry) || entry.length < 2) {
continue;
}
if (normalizeText(entry[0] || "").toLowerCase() === target) {
return normalizeText(entry[1] || "");
}
}
return "";
}
if (typeof headers === "object") {
const keys = Object.keys(headers);
for (let index = 0; index < keys.length; index += 1) {
const key = keys[index];
if (normalizeText(key || "").toLowerCase() === target) {
return normalizeText(headers[key] || "");
}
}
}
return "";
}
function isApiProviderUrl(url) {
const value = normalizeText(url || "");
if (!value) {
return false;
}
try {
return normalizeText(new URL(value, location.href).host).toLowerCase() === API_PROVIDER;
} catch (_) {
return false;
}
}
function captureAuthTokenFromAuthorizationHeader(value, source = "request-header") {
const token = extractAuthTokenFromAuthorizationHeader(value);
if (!token) {
return "";
}
const previousToken = normalizeText(state.apiAutomation.authToken || "");
const previousSource = normalizeText(state.apiAutomation.authTokenSource || "");
const normalizedSource = normalizeText(source || "request-header");
cacheResolvedAuthToken(token, normalizedSource);
if (token !== previousToken || normalizedSource !== previousSource) {
logDebug("api", "Auth token captured from runtime request header.", {
source: normalizedSource,
tokenLength: token.length,
});
refreshRuntimeStatusUi();
}
return token;
}
function captureAuthTokenFromRequestHeaders(headers, options = {}) {
const requestUrl = normalizeText(options.requestUrl || "");
if (requestUrl && !isApiProviderUrl(requestUrl)) {
return "";
}
const authorizationHeader = getHeaderValueCaseInsensitive(headers, "authorization");
if (!authorizationHeader) {
return "";
}
return captureAuthTokenFromAuthorizationHeader(
authorizationHeader,
normalizeText(options.source || "request-header"),
);
}
const AUTH_HEADER_CAPTURE_EVENT_NAME = "ata:auth-header-captured";
const AUTH_HEADER_CAPTURE_DEFAULT_SOURCE = "request-header:bridge";
const AUTH_HEADER_CAPTURE_PAGE_FLAG = "__ataAuthHeaderBridgeInstalled";
function buildPageContextAuthHeaderBridgeScript() {
return `
(() => {
const EVENT_NAME = ${JSON.stringify(AUTH_HEADER_CAPTURE_EVENT_NAME)};
const DEFAULT_SOURCE = ${JSON.stringify(AUTH_HEADER_CAPTURE_DEFAULT_SOURCE)};
const PAGE_FLAG = ${JSON.stringify(AUTH_HEADER_CAPTURE_PAGE_FLAG)};
const API_HOST = ${JSON.stringify(API_PROVIDER)};
const META_KEY = "__ataAuthCaptureMeta";
const normalize = (value) => String(value == null ? "" : value).trim();
const extractToken = (value) => {
const rawValue = normalize(value);
if (!rawValue) {
return "";
}
if (/^basic\\s+/i.test(rawValue)) {
return "";
}
if (/^[a-z-]+\\s+/i.test(rawValue) && !/^bearer\\s+/i.test(rawValue)) {
return "";
}
return normalize(rawValue.replace(/^bearer\\s+/i, ""));
};
const isApiUrl = (url) => {
const value = normalize(url);
if (!value) {
return false;
}
try {
return normalize(new URL(value, location.href).host).toLowerCase() === API_HOST;
} catch (_) {
return false;
}
};
const readHeaderValue = (headers, name) => {
const target = normalize(name).toLowerCase();
if (!target || !headers) {
return "";
}
if (typeof Headers !== "undefined" && headers instanceof Headers) {
return normalize(headers.get(target) || "");
}
if (Array.isArray(headers)) {
for (let index = 0; index < headers.length; index += 1) {
const entry = headers[index];
if (!Array.isArray(entry) || entry.length < 2) {
continue;
}
if (normalize(entry[0] || "").toLowerCase() === target) {
return normalize(entry[1] || "");
}
}
return "";
}
if (typeof headers === "object") {
const keys = Object.keys(headers);
for (let index = 0; index < keys.length; index += 1) {
const key = keys[index];
if (normalize(key || "").toLowerCase() === target) {
return normalize(headers[key] || "");
}
}
}
return "";
};
const emit = (authorizationHeader, source, requestUrl) => {
const request = normalize(requestUrl);
if (request && !isApiUrl(request)) {
return;
}
const token = extractToken(authorizationHeader);
if (!token) {
return;
}
window.dispatchEvent(new CustomEvent(EVENT_NAME, {
detail: {
token,
source: normalize(source || DEFAULT_SOURCE) || DEFAULT_SOURCE,
requestUrl: request,
},
}));
};
try {
if (window[PAGE_FLAG]) {
return;
}
window[PAGE_FLAG] = true;
const xhrPrototype = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
if (xhrPrototype && typeof xhrPrototype.open === "function" && typeof xhrPrototype.setRequestHeader === "function") {
const originalOpen = xhrPrototype.open;
const originalSetRequestHeader = xhrPrototype.setRequestHeader;
const originalSend = typeof xhrPrototype.send === "function" ? xhrPrototype.send : null;
xhrPrototype.open = function ataAuthBridgeOpen(method, url) {
this[META_KEY] = {
requestUrl: normalize(url || ""),
headers: {},
};
return originalOpen.apply(this, arguments);
};
xhrPrototype.setRequestHeader = function ataAuthBridgeSetRequestHeader(name, value) {
try {
const meta = this[META_KEY] || { requestUrl: "", headers: {} };
const normalizedName = normalize(name || "").toLowerCase();
meta.headers[normalizedName] = normalize(value || "");
this[META_KEY] = meta;
if (normalizedName === "authorization") {
emit(meta.headers.authorization, "request-header:xhr", meta.requestUrl);
}
} catch (_) {
// Ignore capture errors to avoid impacting host runtime.
}
return originalSetRequestHeader.apply(this, arguments);
};
if (originalSend) {
xhrPrototype.send = function ataAuthBridgeSend() {
try {
const meta = this[META_KEY];
emit(meta?.headers?.authorization || "", "request-header:xhr", meta?.requestUrl || "");
} catch (_) {
// Ignore capture errors to avoid impacting host runtime.
}
return originalSend.apply(this, arguments);
};
}
}
if (typeof window.fetch === "function") {
const originalFetch = window.fetch;
window.fetch = function ataAuthBridgeFetch(input, init) {
try {
const requestUrl = normalize(
(typeof input === "string" ? input : (input?.url || ""))
|| (typeof init?.url === "string" ? init.url : ""),
);
emit(readHeaderValue(init?.headers, "authorization"), "request-header:fetch", requestUrl);
if (input && typeof input === "object") {
emit(readHeaderValue(input.headers, "authorization"), "request-header:fetch", requestUrl);
}
} catch (_) {
// Ignore capture errors to avoid impacting host runtime.
}
return originalFetch.apply(this, arguments);
};
}
} catch (_) {
// Ignore bridge-install errors to avoid impacting host runtime.
}
})();
`;
}
function installPageContextAuthHeaderCaptureBridge() {
try {
if (window[AUTH_HEADER_CAPTURE_PAGE_FLAG]) {
return true;
}
const root = document.documentElement || document.head || document.body;
if (!root) {
return false;
}
const script = document.createElement("script");
script.type = "text/javascript";
script.textContent = buildPageContextAuthHeaderBridgeScript();
root.appendChild(script);
script.remove();
return Boolean(window[AUTH_HEADER_CAPTURE_PAGE_FLAG]);
} catch (error) {
logWarn("api", "Page-context auth header bridge installation failed.", error);
return false;
}
}
function installRuntimeAuthHeaderCapture() {
if (!state.apiAutomation.authHeaderCaptureInstalled) {
state.apiAutomation.authHeaderCaptureInstalled = true;
const bridgeEventListener = (event) => {
try {
const detail = event?.detail && typeof event.detail === "object"
? event.detail
: {};
const requestUrl = normalizeText(detail.requestUrl || "");
if (requestUrl && !isApiProviderUrl(requestUrl)) {
return;
}
const source = normalizeText(detail.source || AUTH_HEADER_CAPTURE_DEFAULT_SOURCE)
|| AUTH_HEADER_CAPTURE_DEFAULT_SOURCE;
const authorizationValue = normalizeText(detail.token || detail.authorization || "");
if (!authorizationValue) {
return;
}
captureAuthTokenFromAuthorizationHeader(authorizationValue, source);
} catch (error) {
logWarn("api", "Auth header capture bridge event handling failed.", error);
}
};
window.addEventListener(AUTH_HEADER_CAPTURE_EVENT_NAME, bridgeEventListener, true);
addCleanup(() => {
window.removeEventListener(AUTH_HEADER_CAPTURE_EVENT_NAME, bridgeEventListener, true);
});
}
if (!state.apiAutomation.authHeaderBridgeInjected) {
state.apiAutomation.authHeaderBridgeInjected = installPageContextAuthHeaderCaptureBridge();
}
}
function getAuthStateSnapshot() {
installRuntimeAuthHeaderCapture();
const cookieToken = getAuthTokenFromCookie();
const refreshToken = getRefreshTokenFromStorage();
const cachedToken = normalizeText(state.apiAutomation.authToken || "");
const hasCookieToken = Boolean(cookieToken);
const hasRefreshToken = Boolean(refreshToken);
const hasCachedToken = Boolean(cachedToken);
return {
hasCookieToken,
hasRefreshToken,
hasCachedToken,
hasAnyAuthContext: hasCookieToken || hasRefreshToken || hasCachedToken,
cookieTokenLength: cookieToken.length,
refreshTokenLength: refreshToken.length,
cachedTokenLength: cachedToken.length,
cachedTokenUsable: isCachedAuthTokenUsable(),
source: hasCookieToken
? "cookie"
: (hasCachedToken ? "cache" : (hasRefreshToken ? "refresh-token" : "none")),
};
}
async function refreshAuthTokenFromStorageToken(refreshToken) {
const payload = {
refresh_token: normalizeText(refreshToken || ""),
client_id: API_AUTH_CLIENT_ID,
};
const response = await fetch(`${API_AUTH_BASE}/refresh`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
cache: "no-store",
credentials: "omit",
body: JSON.stringify(payload),
});
const text = await response.text();
const body = parseJsonOrText(text);
if (!response.ok) {
throw createApiError(response.status, extractApiErrorMessage(response.status, body), body);
}
return body || {};
}
async function resolveAuthToken(options = {}) {
installRuntimeAuthHeaderCapture();
const forceRefresh = Boolean(options.forceRefresh);
const cookieToken = getAuthTokenFromCookie();
if (cookieToken && !forceRefresh) {
return cacheResolvedAuthToken(cookieToken, "cookie");
}
if (!forceRefresh && isCachedAuthTokenUsable()) {
state.apiAutomation.authTokenSource = state.apiAutomation.authTokenSource || "cache";
return normalizeText(state.apiAutomation.authToken || "");
}
const refreshToken = getRefreshTokenFromStorage();
if (!refreshToken) {
cacheResolvedAuthToken("", "");
return "";
}
if (!forceRefresh && state.apiAutomation.authRefreshPromise) {
try {
return await state.apiAutomation.authRefreshPromise;
} catch (_) {
return "";
}
}
state.apiAutomation.authRefreshPromise = (async () => {
try {
const refreshed = await refreshAuthTokenFromStorageToken(refreshToken);
const accessToken = normalizeText(refreshed?.access_token || "");
if (!accessToken) {
cacheResolvedAuthToken("", "");
return "";
}
const nextRefreshToken = normalizeText(refreshed?.refresh_token || "");
if (nextRefreshToken && nextRefreshToken !== refreshToken) {
try {
localStorage.setItem("autodarts_refresh_token", nextRefreshToken);
} catch (_) {
// Ignore storage write failures.
}
}
const expiresInMs = Math.max(0, Number(refreshed?.expires_in || 0)) * 1000;
return cacheResolvedAuthToken(accessToken, "refresh", Date.now() + expiresInMs);
} catch (error) {
logWarn("api", "Auth token refresh via refresh_token failed.", error);
cacheResolvedAuthToken("", "");
return "";
} finally {
state.apiAutomation.authRefreshPromise = null;
}
})();
return state.apiAutomation.authRefreshPromise;
}
function getBoardId() {
try {
const rawBoardValue = localStorage.getItem("autodarts-board");
if (!rawBoardValue) {
return "";
}
let boardId = rawBoardValue;
try {
const parsed = JSON.parse(rawBoardValue);
if (typeof parsed === "string") {
boardId = parsed;
} else if (parsed && typeof parsed === "object") {
boardId = normalizeText(parsed.id || parsed.boardId || parsed.uuid || parsed.value || "");
}
} catch (_) {
// Keep raw value when localStorage entry is not JSON encoded.
}
return normalizeText(String(boardId || "").replace(/^"+|"+$/g, ""));
} catch (_) {
return "";
}
}
function isValidBoardId(boardId) {
const value = normalizeText(boardId || "");
if (!value) {
return false;
}
if (value === "[object Object]" || value.toLowerCase() === "manual") {
return false;
}
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) {
return true;
}
return /^[a-z0-9][a-z0-9-]{7,}$/i.test(value);
}
function collectRuntimeStatus() {
const authState = getAuthStateSnapshot();
const boardId = getBoardId();
const boardPreview = boardId.length > 18 ? `${boardId.slice(0, 7)}...${boardId.slice(-6)}` : boardId;
const autoEnabled = Boolean(state.store?.settings?.featureFlags?.autoLobbyStart);
const authBlocked = Number(state.apiAutomation?.authBackoffUntil || 0) > Date.now();
const hasToken = authState.hasAnyAuthContext;
const hasBoard = isValidBoardId(boardId);
const hasBoardValue = Boolean(boardId);
return {
hasToken,
hasBoard,
boardId: boardId || "",
autoEnabled,
authBlocked,
apiLabel: hasToken ? (authBlocked ? "API Auth abgelaufen" : "API Auth bereit") : "API Auth fehlt",
boardLabel: hasBoard
? `Board aktiv (${boardPreview})`
: hasBoardValue
? `Board-ID ung\u00fcltig (${boardPreview})`
: "Kein aktives Board",
autoLabel: autoEnabled ? "Auto-Lobby ON" : "Auto-Lobby OFF",
};
}
function renderRuntimeStatusBar() {
const status = collectRuntimeStatus();
const apiStateClass = status.hasToken && !status.authBlocked ? "ata-status-ok" : "ata-status-warn";
const boardStateClass = status.hasBoard ? "ata-status-ok" : "ata-status-warn";
const autoStateClass = status.autoEnabled ? "ata-status-info" : "ata-status-neutral";
const hint = status.autoEnabled && (!status.hasToken || !status.hasBoard)
? renderDocLinkableMessage("Hinweis: F\u00fcr API-Halbautomatik werden Auth-Token und aktives Board ben\u00f6tigt.", {
tagName: "span",
className: "ata-runtime-hint",
})
: "";
return `
${renderDocLinkableMessage(status.apiLabel, {
tagName: "span",
className: `ata-status-pill ${apiStateClass}`,
})}
${renderDocLinkableMessage(status.boardLabel, {
tagName: "span",
className: `ata-status-pill ${boardStateClass}`,
})}
${renderDocLinkableMessage(status.autoLabel, {
tagName: "span",
className: `ata-status-pill ${autoStateClass}`,
})}
${hint}
`;
}
function runtimeStatusSignature() {
const status = collectRuntimeStatus();
return [
status.hasToken ? "1" : "0",
status.authBlocked ? "1" : "0",
status.boardId || "",
status.autoEnabled ? "1" : "0",
].join("|");
}
function refreshRuntimeStatusUi() {
const signature = runtimeStatusSignature();
if (signature === state.runtimeStatusSignature) {
return;
}
state.runtimeStatusSignature = signature;
if (state.drawerOpen) {
renderShell();
}
}
function createApiError(status, message, body) {
const error = new Error(String(message || "API request failed."));
error.status = Number(status || 0);
error.body = body;
return error;
}
function apiBodyToErrorText(value, depth = 0) {
if (value == null || depth > 3) {
return "";
}
if (typeof value === "string") {
return normalizeText(value);
}
if (Array.isArray(value)) {
const parts = value.map((entry) => apiBodyToErrorText(entry, depth + 1)).filter(Boolean);
return normalizeText(parts.join(" | "));
}
if (typeof value === "object") {
const parts = [];
["message", "error", "detail", "title", "reason", "description"].forEach((key) => {
if (Object.prototype.hasOwnProperty.call(value, key)) {
const text = apiBodyToErrorText(value[key], depth + 1);
if (text) {
parts.push(text);
}
}
});
if (value.errors && typeof value.errors === "object") {
Object.entries(value.errors).forEach(([key, entry]) => {
const text = apiBodyToErrorText(entry, depth + 1);
if (text) {
parts.push(`${key}: ${text}`);
}
});
}
const deduped = [...new Set(parts.map((entry) => normalizeText(entry)).filter(Boolean))];
if (deduped.length) {
return normalizeText(deduped.join(" | "));
}
try {
const json = JSON.stringify(value);
if (json && json !== "{}") {
return normalizeText(json);
}
} catch (_) {
return "";
}
return "";
}
return normalizeText(String(value));
}
function extractApiErrorMessage(status, body) {
const detail = apiBodyToErrorText(body);
if (detail) {
return `HTTP ${status}: ${detail}`;
}
return `HTTP ${status}`;
}
function parseJsonOrText(rawText) {
const text = String(rawText || "");
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch (_) {
return text;
}
}
async function requestJsonViaGm(method, url, payload, headers) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
timeout: API_REQUEST_TIMEOUT_MS,
headers,
data: payload ? JSON.stringify(payload) : undefined,
onload: (response) => {
const status = Number(response?.status || 0);
const body = parseJsonOrText(response?.responseText || "");
if (status >= 200 && status < 300) {
resolve(body || {});
return;
}
reject(createApiError(status, extractApiErrorMessage(status, body), body));
},
onerror: () => {
reject(createApiError(0, "Netzwerkfehler bei API-Anfrage.", null));
},
ontimeout: () => {
reject(createApiError(0, "API-Anfrage Timeout.", null));
},
});
});
}
async function requestJsonViaFetch(method, url, payload, headers) {
const response = await fetch(url, {
method,
headers,
body: payload ? JSON.stringify(payload) : undefined,
cache: "no-store",
credentials: "omit",
});
const text = await response.text();
const body = parseJsonOrText(text);
if (!response.ok) {
throw createApiError(response.status, extractApiErrorMessage(response.status, body), body);
}
return body || {};
}
async function apiRequestJson(method, url, payload, token) {
const headers = { Accept: "application/json" };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (payload) {
headers["Content-Type"] = "application/json";
}
if (typeof GM_xmlhttpRequest === "function") {
try {
return await requestJsonViaGm(method, url, payload, headers);
} catch (error) {
const status = Number(error?.status || 0);
if (status > 0) {
throw error;
}
logWarn("api", "GM_xmlhttpRequest failed, falling back to fetch().", error);
}
}
return requestJsonViaFetch(method, url, payload, headers);
}
async function createLobby(payload, token) {
return apiRequestJson("POST", `${API_GS_BASE}/lobbies`, payload, token);
}
async function deleteLobby(lobbyId, token) {
return apiRequestJson("DELETE", `${API_GS_BASE}/lobbies/${encodeURIComponent(lobbyId)}`, null, token);
}
async function addLobbyPlayer(lobbyId, name, boardId, token) {
return apiRequestJson("POST", `${API_GS_BASE}/lobbies/${encodeURIComponent(lobbyId)}/players`, { name, boardId }, token);
}
async function startLobby(lobbyId, token) {
return apiRequestJson("POST", `${API_GS_BASE}/lobbies/${encodeURIComponent(lobbyId)}/start`, null, token);
}
async function fetchMatchStats(lobbyId, token) {
return apiRequestJson("GET", `${API_AS_BASE}/matches/${encodeURIComponent(lobbyId)}/stats`, null, token);
}
function getRouteLobbyId(pathname = location.pathname) {
const route = normalizeText(pathname || "");
if (!route) {
return "";
}
const match = route.match(/^\/(?:(?:history\/)?(?:matches|lobbies))\/([^/?#]+)/i);
if (!match || !match[1]) {
return "";
}
try {
return normalizeText(decodeURIComponent(match[1]));
} catch (_) {
return normalizeText(match[1]);
}
}
function isApiSyncCandidateMatch(match, includeErrored = false) {
if (!match || match.status !== STATUS_PENDING) {
return false;
}
const auto = ensureMatchAutoMeta(match);
if (!auto.lobbyId) {
return false;
}
if (auto.status === "started") {
return true;
}
return includeErrored && auto.status === "error";
}
installRuntimeAuthHeaderCapture();
function findTournamentMatchByLobbyId(tournament, lobbyId, includeCompleted = false) {
const targetLobbyId = normalizeText(lobbyId || "");
if (!tournament || !targetLobbyId) {
return null;
}
return tournament.matches.find((match) => {
if (!includeCompleted && match.status !== STATUS_PENDING) {
return false;
}
const auto = ensureMatchAutoMeta(match);
return normalizeText(auto.lobbyId || "") === targetLobbyId;
}) || null;
}
async function syncApiMatchResult(tournament, match, token, options = {}) {
const notifyErrors = Boolean(options.notifyErrors);
const notifyNotReady = Boolean(options.notifyNotReady);
const includeErrorRetry = options.includeErrorRetry !== false;
const trigger = normalizeText(options.trigger || "background");
const auto = ensureMatchAutoMeta(match);
const lobbyId = normalizeText(auto.lobbyId || "");
if (!lobbyId) {
return {
ok: false,
updated: false,
completed: false,
pending: true,
reasonCode: "not_found",
message: "Keine Lobby-ID vorhanden.",
};
}
if (!includeErrorRetry && auto.status === "error") {
return {
ok: false,
updated: false,
completed: false,
pending: true,
reasonCode: "error",
message: "Match ist im Fehlerstatus.",
};
}
let updated = false;
if (auto.status === "error") {
auto.status = "started";
auto.lastSyncAt = nowIso();
match.updatedAt = nowIso();
updated = true;
}
try {
const stats = options.prefetchedStats || await fetchMatchStats(lobbyId, token);
const syncTimestamp = nowIso();
if (auto.lastError) {
auto.lastError = null;
auto.lastSyncAt = syncTimestamp;
match.updatedAt = syncTimestamp;
updated = true;
} else if (!auto.lastSyncAt) {
auto.lastSyncAt = syncTimestamp;
match.updatedAt = syncTimestamp;
updated = true;
}
const winnerIndex = Number(stats?.winner);
if (!Number.isInteger(winnerIndex) || winnerIndex < 0) {
auto.status = "started";
if (!auto.lastSyncAt) {
auto.lastSyncAt = syncTimestamp;
}
if (notifyNotReady) {
setNotice("info", "API-Ergebnis ist noch nicht final verf\u00fcgbar.", 2200);
}
return {
ok: true,
updated,
completed: false,
pending: true,
recoverable: true,
reasonCode: "pending",
message: "API-Ergebnis ist noch nicht final verf\u00fcgbar.",
};
}
const winnerCandidates = resolveWinnerIdCandidatesFromApiStats(tournament, match, stats, winnerIndex);
logDebug("api", "Auto-sync winner candidates resolved.", {
trigger,
matchId: match.id,
lobbyId,
winnerIndex,
winnerCandidates,
});
if (!winnerCandidates.length) {
const mappingError = "Gewinner konnte nicht eindeutig zugeordnet werden.";
const changedError = auto.lastError !== mappingError || auto.status !== "error";
auto.status = "error";
auto.lastError = mappingError;
auto.lastSyncAt = syncTimestamp;
match.updatedAt = syncTimestamp;
updated = true;
if (notifyErrors && changedError) {
setNotice("error", `Auto-Sync Fehler bei ${match.id}: Gewinner nicht zuordenbar.`);
}
return {
ok: false,
updated,
completed: false,
pending: true,
recoverable: false,
reasonCode: "error",
message: mappingError,
};
}
let result = { ok: false, message: "Auto-Sync konnte Ergebnis nicht speichern." };
for (const winnerId of winnerCandidates) {
const legCandidates = getApiMatchLegCandidatesFromStats(tournament, match, stats, winnerId);
logDebug("api", "Auto-sync leg candidates resolved.", {
trigger,
matchId: match.id,
winnerId,
legCandidates,
});
for (const legs of legCandidates) {
result = updateMatchResult(match.id, winnerId, legs, "auto");
if (result.ok) {
break;
}
}
if (result.ok) {
break;
}
}
if (!result.ok) {
logWarn("api", "Auto-sync could not persist result with resolved winner/legs candidates.", {
trigger,
matchId: match.id,
winnerCandidates,
winnerIndex,
});
const saveError = result.message || "Auto-Sync konnte Ergebnis nicht speichern.";
const changedError = auto.lastError !== saveError || auto.status !== "error";
auto.status = "error";
auto.lastError = saveError;
auto.lastSyncAt = syncTimestamp;
match.updatedAt = syncTimestamp;
updated = true;
if (notifyErrors && changedError) {
setNotice("error", `Auto-Sync Fehler bei ${match.id}: ${saveError}`);
}
return {
ok: false,
updated,
completed: false,
pending: true,
recoverable: false,
reasonCode: "error",
message: saveError,
};
}
const updatedMatch = findMatch(tournament, match.id);
if (updatedMatch) {
const updatedAuto = ensureMatchAutoMeta(updatedMatch);
const finishedAt = nowIso();
updatedAuto.provider = API_PROVIDER;
updatedAuto.status = "completed";
updatedAuto.finishedAt = finishedAt;
updatedAuto.lastSyncAt = finishedAt;
updatedAuto.lastError = null;
updatedMatch.updatedAt = finishedAt;
}
return {
ok: true,
updated: true,
completed: true,
pending: false,
reasonCode: "completed",
message: "Ergebnis \u00fcbernommen.",
};
} catch (error) {
const status = Number(error?.status || 0);
if (status === 401 || status === 403) {
return {
ok: false,
updated,
completed: false,
pending: true,
authError: true,
reasonCode: "auth",
message: "Auth abgelaufen.",
};
}
if (status === 404) {
return {
ok: false,
updated,
completed: false,
pending: true,
recoverable: true,
reasonCode: "pending",
message: "Match-Stats noch nicht verf\u00fcgbar.",
};
}
const errorMessage = normalizeText(error?.message || "API-Sync fehlgeschlagen.") || "API-Sync fehlgeschlagen.";
const lastSyncAtMs = auto.lastSyncAt ? Date.parse(auto.lastSyncAt) : 0;
const shouldPersistError = auto.lastError !== errorMessage
|| !Number.isFinite(lastSyncAtMs)
|| (Date.now() - lastSyncAtMs > API_AUTH_NOTICE_THROTTLE_MS);
if (shouldPersistError) {
auto.status = "error";
auto.lastError = errorMessage;
auto.lastSyncAt = nowIso();
match.updatedAt = nowIso();
updated = true;
}
if (notifyErrors && shouldPersistError) {
setNotice("error", `Auto-Sync Fehler bei ${match.id}: ${errorMessage}`);
}
return {
ok: false,
updated,
completed: false,
pending: true,
recoverable: false,
reasonCode: "error",
message: errorMessage,
};
}
}
async function syncResultForLobbyId(lobbyId, options = {}) {
const targetLobbyId = normalizeText(lobbyId || "");
const trigger = normalizeText(options.trigger || "manual");
const tournament = state.store.tournament;
logDebug("api", "Lobby sync requested.", {
trigger,
lobbyId: targetLobbyId,
});
if (!targetLobbyId) {
return { ok: false, reasonCode: "not_found", message: "Keine Lobby-ID erkannt." };
}
if (!tournament) {
return { ok: false, reasonCode: "error", message: "Kein aktives Turnier vorhanden." };
}
if (!state.store.settings.featureFlags.autoLobbyStart) {
return { ok: false, reasonCode: "error", message: "Auto-Lobby ist deaktiviert." };
}
const token = await resolveAuthToken();
if (!token) {
return { ok: false, reasonCode: "auth", message: "Kein Auth-Token gefunden. Bitte neu einloggen." };
}
let openMatch = findTournamentMatchByLobbyId(tournament, targetLobbyId, false);
const completedMatch = openMatch ? null : findTournamentMatchByLobbyId(tournament, targetLobbyId, true);
if (!openMatch && completedMatch?.status === STATUS_COMPLETED) {
return { ok: true, completed: true, reasonCode: "completed", message: "Ergebnis war bereits \u00fcbernommen." };
}
let prefetchedStats = null;
if (!openMatch) {
try {
prefetchedStats = await fetchMatchStats(targetLobbyId, token);
const recoveredMatches = findOpenMatchCandidatesByApiStats(tournament, prefetchedStats);
logDebug("api", "Recovery match candidates resolved.", {
trigger,
lobbyId: targetLobbyId,
candidateCount: recoveredMatches.length,
candidateMatchIds: recoveredMatches.map((match) => match.id),
});
if (recoveredMatches.length > 1) {
return {
ok: false,
reasonCode: "ambiguous",
message: "Mehrdeutige Zuordnung: mehrere offene Turnier-Matches passen zur Lobby. Bitte in der Ergebnisf\u00fchrung manuell speichern.",
};
}
if (recoveredMatches.length === 1) {
openMatch = recoveredMatches[0];
const auto = ensureMatchAutoMeta(openMatch);
const now = nowIso();
auto.provider = API_PROVIDER;
auto.lobbyId = targetLobbyId;
auto.status = "started";
auto.startedAt = auto.startedAt || now;
auto.lastSyncAt = now;
auto.lastError = null;
openMatch.updatedAt = now;
tournament.updatedAt = now;
try {
await persistStore();
} catch (persistError) {
schedulePersist();
logWarn("storage", "Immediate persist after recovery link failed; scheduled retry.", persistError);
}
renderShell();
}
} catch (error) {
logWarn("api", "Recovery lookup via stats failed.", error);
// Fallback keeps original behavior when stats are not yet available.
}
}
if (!openMatch) {
return { ok: false, reasonCode: "not_found", message: "Kein offenes Turnier-Match f\u00fcr diese Lobby gefunden." };
}
const syncOutcome = await syncApiMatchResult(tournament, openMatch, token, {
notifyErrors: Boolean(options.notifyErrors),
notifyNotReady: Boolean(options.notifyNotReady),
includeErrorRetry: true,
prefetchedStats,
trigger,
});
if (syncOutcome.authError) {
state.apiAutomation.authBackoffUntil = Date.now() + API_AUTH_NOTICE_THROTTLE_MS;
return { ok: false, reasonCode: "auth", message: "Auth abgelaufen. Bitte neu einloggen." };
}
if (syncOutcome.updated) {
if (state.store.tournament) {
state.store.tournament.updatedAt = nowIso();
}
schedulePersist();
renderShell();
}
const normalizedOutcome = {
...syncOutcome,
reasonCode: syncOutcome.reasonCode || (syncOutcome.completed ? "completed" : (syncOutcome.pending ? "pending" : "error")),
};
logDebug("api", "Lobby sync finished.", {
trigger,
lobbyId: targetLobbyId,
reasonCode: normalizedOutcome.reasonCode,
ok: normalizedOutcome.ok,
completed: Boolean(normalizedOutcome.completed),
});
return normalizedOutcome;
}
function getDuplicateParticipantNames(tournament) {
const seen = new Map();
const duplicates = [];
(tournament?.participants || []).forEach((participant) => {
const key = normalizeLookup(participant?.name || "");
if (!key) {
return;
}
if (seen.has(key)) {
duplicates.push(participant.name);
return;
}
seen.set(key, participant.name);
});
return duplicates;
}
function findActiveStartedMatch(tournament, excludeMatchId = "") {
if (!tournament) {
return null;
}
return tournament.matches.find((match) => {
if (excludeMatchId && match.id === excludeMatchId) {
return false;
}
if (match.status !== STATUS_PENDING) {
return false;
}
const auto = ensureMatchAutoMeta(match);
return Boolean(auto.lobbyId && auto.status === "started");
}) || null;
}
function buildLobbyCreatePayload(tournament) {
const legsToWin = getLegsToWin(tournament.bestOfLegs);
const x01Settings = normalizeTournamentX01Settings(tournament?.x01, tournament?.startScore);
const bullOffMode = sanitizeX01BullOffMode(x01Settings.bullOffMode);
const settings = {
baseScore: x01Settings.baseScore,
inMode: x01Settings.inMode,
outMode: x01Settings.outMode,
maxRounds: x01Settings.maxRounds,
bullMode: sanitizeX01BullMode(x01Settings.bullMode),
};
return {
variant: x01Settings.variant,
isPrivate: true,
bullOffMode,
legs: legsToWin,
settings,
};
}
function storeMatchStartDebugSessionIfEnabled(session, outcome, details = {}) {
if (!session || !state.store.settings.debug) {
return null;
}
const finalized = finalizeMatchStartDebugSession(session, outcome, details);
recordMatchStartDebugSession(state.store, finalized);
logDebug("api", "Matchstart debug session stored.", finalized);
return finalized;
}
function openMatchPage(lobbyId) {
if (!normalizeText(lobbyId)) {
return;
}
window.location.href = `${window.location.origin}/matches/${encodeURIComponent(lobbyId)}`;
}
function resolveWinnerIdFromApiName(tournament, match, winnerName) {
const normalizedWinner = normalizeLookup(winnerName);
if (!normalizedWinner || !match?.player1Id || !match?.player2Id) {
return "";
}
const p1 = participantById(tournament, match.player1Id);
const p2 = participantById(tournament, match.player2Id);
const p1Name = normalizeLookup(p1?.name || "");
const p2Name = normalizeLookup(p2?.name || "");
if (!p1Name || !p2Name || p1Name === p2Name) {
return "";
}
if (p1Name === normalizedWinner) {
return match.player1Id;
}
if (p2Name === normalizedWinner) {
return match.player2Id;
}
return "";
}
function resolveParticipantIdFromApiRef(tournament, participantRef) {
const normalizedRef = normalizeText(participantRef || "");
if (!normalizedRef) {
return "";
}
const direct = (tournament?.participants || []).find((participant) => (
normalizeText(participant?.id || "") === normalizedRef
));
if (direct?.id) {
return direct.id;
}
const lookup = normalizeLookup(normalizedRef);
const byName = (tournament?.participants || []).find((participant) => (
normalizeLookup(participant?.name || "") === lookup
));
return byName?.id || "";
}
function getOpenMatchesByPlayersPair(tournament, player1Id, player2Id) {
const key = new Set([normalizeText(player1Id || ""), normalizeText(player2Id || "")]);
if (key.size !== 2 || key.has("")) {
return [];
}
return (tournament?.matches || []).filter((match) => {
if (!match || match.status !== STATUS_PENDING || !match.player1Id || !match.player2Id) {
return false;
}
const set = new Set([normalizeText(match.player1Id), normalizeText(match.player2Id)]);
return key.size === set.size && [...key].every((id) => set.has(id));
});
}
function findOpenMatchCandidatesByApiStats(tournament, data) {
if (!tournament || !data) {
return [];
}
const participantIds = [];
const seenParticipants = new Set();
const pushId = (participantId) => {
const id = normalizeText(participantId || "");
if (!id || seenParticipants.has(id)) {
return;
}
seenParticipants.add(id);
participantIds.push(id);
};
const collectFrom = (entry) => {
const refs = extractApiParticipantRefCandidates(entry);
refs.forEach((ref) => {
pushId(resolveParticipantIdFromApiRef(tournament, ref));
});
};
const statsEntries = Array.isArray(data?.matchStats) ? data.matchStats : [];
statsEntries.forEach((entry) => {
collectFrom(entry);
});
const playerEntries = Array.isArray(data?.players) ? data.players : [];
playerEntries.forEach((entry) => {
collectFrom(entry);
});
if (participantIds.length < 2) {
return [];
}
const matches = [];
const seenMatches = new Set();
for (let left = 0; left < participantIds.length; left += 1) {
for (let right = left + 1; right < participantIds.length; right += 1) {
const candidates = getOpenMatchesByPlayersPair(tournament, participantIds[left], participantIds[right]);
candidates.forEach((match) => {
if (!seenMatches.has(match.id)) {
seenMatches.add(match.id);
matches.push(match);
}
});
}
}
return matches.sort((left, right) => {
if (left.round !== right.round) {
return left.round - right.round;
}
return left.number - right.number;
});
}
function extractApiParticipantRefCandidates(value) {
const refs = [];
const pushRef = (ref) => {
const text = normalizeText(ref || "");
if (text) {
refs.push(text);
}
};
if (!value) {
return refs;
}
if (typeof value === "string" || typeof value === "number") {
pushRef(value);
return refs;
}
if (typeof value !== "object") {
return refs;
}
pushRef(value.id);
pushRef(value.playerId);
pushRef(value.name);
pushRef(value.playerName);
pushRef(value.username);
if (value.player && typeof value.player === "object") {
pushRef(value.player.id);
pushRef(value.player.name);
pushRef(value.player.username);
}
return refs;
}
function resolveWinnerIdFromApiRef(tournament, match, winnerRef) {
const normalizedRef = normalizeText(winnerRef || "");
if (!normalizedRef || !match?.player1Id || !match?.player2Id) {
return "";
}
if (normalizedRef === match.player1Id) {
return match.player1Id;
}
if (normalizedRef === match.player2Id) {
return match.player2Id;
}
return resolveWinnerIdFromApiName(tournament, match, normalizedRef);
}
function pushWinnerIdCandidate(candidates, seen, winnerId) {
const candidate = normalizeText(winnerId || "");
if (!candidate || seen.has(candidate)) {
return;
}
seen.add(candidate);
candidates.push(candidate);
}
function resolveWinnerIdCandidatesFromApiStats(tournament, match, data, winnerIndex) {
const candidates = [];
const seen = new Set();
const tryRefs = (refs) => {
refs.forEach((ref) => {
const winnerId = resolveWinnerIdFromApiRef(tournament, match, ref);
pushWinnerIdCandidate(candidates, seen, winnerId);
});
};
if (Number.isInteger(winnerIndex) && winnerIndex >= 0) {
tryRefs(extractApiParticipantRefCandidates(data?.matchStats?.[winnerIndex]));
tryRefs(extractApiParticipantRefCandidates(data?.players?.[winnerIndex]));
}
tryRefs(extractApiParticipantRefCandidates(data?.winnerPlayer));
tryRefs(extractApiParticipantRefCandidates(data?.winnerEntry));
tryRefs(extractApiParticipantRefCandidates(data?.winnerData));
tryRefs(extractApiParticipantRefCandidates(data?.winnerName));
tryRefs(extractApiParticipantRefCandidates(data?.winnerId));
const matchStats = Array.isArray(data?.matchStats) ? data.matchStats : [];
matchStats.forEach((entry) => {
const hasWinnerFlag = entry?.winner === true
|| entry?.isWinner === true
|| entry?.won === true
|| entry?.result === "winner"
|| entry?.result === "win";
if (hasWinnerFlag) {
tryRefs(extractApiParticipantRefCandidates(entry));
}
});
const players = Array.isArray(data?.players) ? data.players : [];
players.forEach((entry) => {
const hasWinnerFlag = entry?.winner === true
|| entry?.isWinner === true
|| entry?.won === true
|| entry?.result === "winner"
|| entry?.result === "win";
if (hasWinnerFlag) {
tryRefs(extractApiParticipantRefCandidates(entry));
}
});
return candidates;
}
function addApiLegCandidate(candidates, seenKeys, legs) {
if (!legs || typeof legs !== "object") {
return;
}
const p1 = clampInt(legs.p1, 0, 0, 50);
const p2 = clampInt(legs.p2, 0, 0, 50);
const key = `${p1}:${p2}`;
if (seenKeys.has(key)) {
return;
}
seenKeys.add(key);
candidates.push({ p1, p2 });
}
function doesLegsSupportWinner(match, winnerId, legs) {
if (!match || !legs) {
return false;
}
const winnerIsP1 = winnerId === match.player1Id;
const winnerIsP2 = winnerId === match.player2Id;
if (!winnerIsP1 && !winnerIsP2) {
return false;
}
const winnerLegs = winnerIsP1 ? legs.p1 : legs.p2;
const loserLegs = winnerIsP1 ? legs.p2 : legs.p1;
return winnerLegs > loserLegs;
}
function getApiNameToLegsLookup(data) {
const lookup = new Map();
const players = Array.isArray(data?.players) ? data.players : [];
const matchStats = Array.isArray(data?.matchStats) ? data.matchStats : [];
const count = Math.max(players.length, matchStats.length);
for (let index = 0; index < count; index += 1) {
const statEntry = matchStats[index];
const legs = clampInt(statEntry?.legsWon, 0, 0, 50);
const playerName = normalizeLookup(players[index]?.name || "");
const statName = normalizeLookup(
statEntry?.name
|| statEntry?.playerName
|| statEntry?.player?.name
|| statEntry?.player?.username
|| "",
);
if (playerName && !lookup.has(playerName)) {
lookup.set(playerName, legs);
}
if (statName && !lookup.has(statName)) {
lookup.set(statName, legs);
}
}
return lookup;
}
function getApiMatchLegCandidatesFromStats(tournament, match, data, winnerId) {
const candidates = [];
const seen = new Set();
const positional = {
p1: clampInt(data?.matchStats?.[0]?.legsWon, 0, 0, 50),
p2: clampInt(data?.matchStats?.[1]?.legsWon, 0, 0, 50),
};
addApiLegCandidate(candidates, seen, positional);
addApiLegCandidate(candidates, seen, { p1: positional.p2, p2: positional.p1 });
const p1 = participantById(tournament, match?.player1Id);
const p2 = participantById(tournament, match?.player2Id);
const p1Name = normalizeLookup(p1?.name || "");
const p2Name = normalizeLookup(p2?.name || "");
if (p1Name && p2Name && p1Name !== p2Name) {
const nameToLegs = getApiNameToLegsLookup(data);
if (nameToLegs.has(p1Name) && nameToLegs.has(p2Name)) {
addApiLegCandidate(candidates, seen, {
p1: nameToLegs.get(p1Name),
p2: nameToLegs.get(p2Name),
});
}
}
const winnerIndex = Number(data?.winner);
if (Number.isInteger(winnerIndex) && winnerIndex >= 0) {
const winnerLegs = clampInt(data?.matchStats?.[winnerIndex]?.legsWon, 0, 0, 50);
const loserIndex = winnerIndex === 0 ? 1 : 0;
const loserLegs = clampInt(data?.matchStats?.[loserIndex]?.legsWon, 0, 0, 50);
if (winnerId === match?.player1Id) {
addApiLegCandidate(candidates, seen, { p1: winnerLegs, p2: loserLegs });
} else if (winnerId === match?.player2Id) {
addApiLegCandidate(candidates, seen, { p1: loserLegs, p2: winnerLegs });
}
}
if (!candidates.length) {
return [positional];
}
const preferred = [];
const fallback = [];
candidates.forEach((candidate) => {
if (doesLegsSupportWinner(match, winnerId, candidate)) {
preferred.push(candidate);
} else {
fallback.push(candidate);
}
});
return preferred.concat(fallback);
}
function getApiMatchStartUi(tournament, match, activeStartedMatch) {
const auto = ensureMatchAutoMeta(match);
if (auto.lobbyId) {
return {
label: "Zum Match",
disabled: false,
title: "\u00d6ffnet das bereits gestartete Match.",
};
}
if (!state.store.settings.featureFlags.autoLobbyStart) {
return {
label: "Match starten",
disabled: true,
title: "Feature-Flag in Einstellungen aktivieren.",
};
}
const editability = getMatchEditability(tournament, match);
if (!editability.editable) {
return {
label: "Match starten",
disabled: true,
title: editability.reason || "Match kann aktuell nicht gestartet werden.",
};
}
const duplicates = getDuplicateParticipantNames(tournament);
if (duplicates.length) {
return {
label: "Match starten",
disabled: true,
title: "Teilnehmernamen müssen für Auto-Sync eindeutig sein.",
};
}
const authState = getAuthStateSnapshot();
if (!authState.hasAnyAuthContext) {
return {
label: "Match starten",
disabled: true,
title: "Kein Auth-Token vorhanden. Bitte einloggen.",
};
}
const boardId = getBoardId();
if (!isValidBoardId(boardId)) {
return {
label: "Match starten",
disabled: true,
title: boardId
? `Board-ID ung\u00fcltig (${boardId}). Bitte Board in einer manuellen Lobby w\u00e4hlen.`
: "Kein Board aktiv. Bitte einmal manuell eine Lobby \u00f6ffnen und Board w\u00e4hlen.",
};
}
if (activeStartedMatch && activeStartedMatch.id !== match.id) {
return {
label: "Match starten",
disabled: true,
title: "Es l\u00e4uft bereits ein aktives Match.",
};
}
if (state.apiAutomation.startingMatchId && state.apiAutomation.startingMatchId !== match.id) {
return {
label: "Match starten",
disabled: true,
title: "Ein anderer Matchstart l\u00e4uft bereits.",
};
}
if (state.apiAutomation.startingMatchId === match.id) {
return {
label: "Startet...",
disabled: true,
title: "Lobby wird erstellt.",
};
}
return {
label: "Match starten",
disabled: false,
title: "Erstellt Lobby, f\u00fcgt Spieler hinzu und startet automatisch.",
};
}
function getApiMatchStatusText(match) {
if (isByeMatchResult(match)) {
return "Freilos (Bye): kein API-Sync erforderlich";
}
const auto = ensureMatchAutoMeta(match);
if (auto.status === "completed") {
return "API-Sync: abgeschlossen";
}
if (auto.status === "started" && auto.lobbyId) {
return `API-Sync: aktiv (Lobby ${auto.lobbyId})`;
}
if (auto.status === "error") {
return `API-Sync: Fehler${auto.lastError ? ` (${auto.lastError})` : ""}`;
}
return "API-Sync: nicht gestartet";
}
async function handleStartMatch(matchId) {
const tournament = state.store.tournament;
if (!tournament) {
return;
}
const match = findMatch(tournament, matchId);
if (!match) {
setNotice("error", "Match nicht gefunden.");
return;
}
const auto = ensureMatchAutoMeta(match);
const editability = getMatchEditability(tournament, match);
const duplicates = getDuplicateParticipantNames(tournament);
const activeMatch = findActiveStartedMatch(tournament, match.id);
const authState = getAuthStateSnapshot();
let token = "";
const boardId = getBoardId();
const participant1 = participantById(tournament, match.player1Id);
const participant2 = participantById(tournament, match.player2Id);
const preflight = {
route: routeKey(),
autoLobbyEnabled: Boolean(state.store.settings.featureFlags.autoLobbyStart),
hasExistingLobby: Boolean(auto.lobbyId),
editability: {
editable: Boolean(editability.editable),
reason: normalizeText(editability.reason || ""),
},
duplicateNames: duplicates.slice(),
activeStartedMatchId: normalizeText(activeMatch?.id || ""),
tokenPresent: Boolean(authState.hasAnyAuthContext),
tokenSources: {
hasCookieToken: Boolean(authState.hasCookieToken),
hasRefreshToken: Boolean(authState.hasRefreshToken),
hasCachedToken: Boolean(authState.hasCachedToken),
},
boardId: normalizeText(boardId || ""),
boardValid: isValidBoardId(boardId),
participant1Name: normalizeText(participant1?.name || ""),
participant2Name: normalizeText(participant2?.name || ""),
currentStartingMatchId: normalizeText(state.apiAutomation.startingMatchId || ""),
};
const debugSession = state.store.settings.debug
? createMatchStartDebugSession({
route: preflight.route,
tournamentId: normalizeText(tournament.id || ""),
tournamentName: normalizeText(tournament.name || ""),
tournamentMode: normalizeText(tournament.mode || ""),
bestOfLegs: Number(tournament.bestOfLegs || 0),
startScore: Number(tournament.startScore || 0),
matchId: normalizeText(match.id || ""),
participant1Name: preflight.participant1Name,
participant2Name: preflight.participant2Name,
})
: null;
const recordDebugStep = (step, status, details = null) => {
if (!debugSession) {
return;
}
appendMatchStartDebugStep(debugSession, step, status, details);
logDebug("api", `Matchstart ${step} [${status}]`, details || {});
};
const persistDebugBeforeNavigation = async () => {
if (!debugSession) {
return;
}
try {
await persistStore();
} catch (persistError) {
schedulePersist();
logWarn("storage", "Persisting matchstart debug before navigation failed; scheduled retry.", persistError);
}
};
const scheduleDebugPersist = () => {
if (debugSession) {
schedulePersist();
}
};
if (debugSession) {
debugSession.context.preflight = cloneSerializable(preflight) || {};
recordDebugStep("preflight", "info", preflight);
}
if (auto.lobbyId) {
recordDebugStep("open_existing_lobby", "info", { lobbyId: auto.lobbyId });
storeMatchStartDebugSessionIfEnabled(debugSession, "existing_lobby", {
lobbyId: auto.lobbyId,
summary: {
reasonCode: "existing_lobby",
message: "Match war bereits mit einer Lobby verknüpft.",
},
});
await persistDebugBeforeNavigation();
openMatchPage(auto.lobbyId);
return;
}
if (!state.store.settings.featureFlags.autoLobbyStart) {
recordDebugStep("blocked", "info", { reasonCode: "autolobby_disabled" });
storeMatchStartDebugSessionIfEnabled(debugSession, "blocked", {
summary: {
reasonCode: "autolobby_disabled",
message: "Auto-Lobby ist deaktiviert.",
},
});
scheduleDebugPersist();
setNotice("info", "Auto-Lobby ist deaktiviert. Bitte im Tab Einstellungen aktivieren.");
return;
}
if (!editability.editable) {
recordDebugStep("blocked", "error", {
reasonCode: "match_not_editable",
reason: editability.reason || "Match kann aktuell nicht gestartet werden.",
});
storeMatchStartDebugSessionIfEnabled(debugSession, "blocked", {
summary: {
reasonCode: "match_not_editable",
message: editability.reason || "Match kann aktuell nicht gestartet werden.",
},
});
scheduleDebugPersist();
setNotice("error", editability.reason || "Match kann aktuell nicht gestartet werden.");
return;
}
if (duplicates.length) {
recordDebugStep("blocked", "error", {
reasonCode: "duplicate_names",
duplicateNames: duplicates,
});
storeMatchStartDebugSessionIfEnabled(debugSession, "blocked", {
summary: {
reasonCode: "duplicate_names",
message: "Für Auto-Sync müssen Teilnehmernamen eindeutig sein.",
},
});
scheduleDebugPersist();
setNotice("error", "F\u00fcr Auto-Sync m\u00fcssen Teilnehmernamen eindeutig sein.");
return;
}
if (activeMatch) {
const activeAuto = ensureMatchAutoMeta(activeMatch);
recordDebugStep("blocked", "info", {
reasonCode: "active_match_exists",
activeMatchId: activeMatch.id,
activeLobbyId: normalizeText(activeAuto.lobbyId || ""),
});
storeMatchStartDebugSessionIfEnabled(debugSession, "blocked", {
lobbyId: normalizeText(activeAuto.lobbyId || "") || null,
summary: {
reasonCode: "active_match_exists",
message: "Es läuft bereits ein aktives Match.",
activeMatchId: activeMatch.id,
},
});
setNotice("info", "Es l\u00e4uft bereits ein aktives Match. Weiterleitung dorthin.");
if (activeAuto.lobbyId) {
await persistDebugBeforeNavigation();
openMatchPage(activeAuto.lobbyId);
} else {
scheduleDebugPersist();
}
return;
}
recordDebugStep("resolve_auth_token", "pending", {
hasAnyAuthContext: Boolean(authState.hasAnyAuthContext),
hasCookieToken: Boolean(authState.hasCookieToken),
hasRefreshToken: Boolean(authState.hasRefreshToken),
hasCachedToken: Boolean(authState.hasCachedToken),
});
token = await resolveAuthToken();
if (!token) {
recordDebugStep("blocked", "error", { reasonCode: "missing_auth_token" });
storeMatchStartDebugSessionIfEnabled(debugSession, "blocked", {
summary: {
reasonCode: "missing_auth_token",
message: "Kein Autodarts-Token gefunden. Bitte einloggen und Seite neu laden.",
},
});
scheduleDebugPersist();
setNotice("error", "Kein Autodarts-Token gefunden. Bitte einloggen und Seite neu laden.");
return;
}
recordDebugStep("resolve_auth_token", "ok", {
source: normalizeText(state.apiAutomation.authTokenSource || "") || "unknown",
tokenLength: token.length,
});
if (!boardId) {
recordDebugStep("blocked", "error", { reasonCode: "missing_board_id" });
storeMatchStartDebugSessionIfEnabled(debugSession, "blocked", {
summary: {
reasonCode: "missing_board_id",
message: "Board-ID fehlt. Bitte einmal manuell eine Lobby öffnen und Board auswählen.",
},
});
scheduleDebugPersist();
setNotice("error", "Board-ID fehlt. Bitte einmal manuell eine Lobby \u00f6ffnen und Board ausw\u00e4hlen.");
return;
}
if (!isValidBoardId(boardId)) {
recordDebugStep("blocked", "error", {
reasonCode: "invalid_board_id",
boardId,
});
storeMatchStartDebugSessionIfEnabled(debugSession, "blocked", {
summary: {
reasonCode: "invalid_board_id",
message: `Board-ID ist ungültig (${boardId}). Bitte in einer manuellen Lobby ein echtes Board auswählen.`,
},
});
scheduleDebugPersist();
setNotice("error", `Board-ID ist ung\u00fcltig (${boardId}). Bitte in einer manuellen Lobby ein echtes Board ausw\u00e4hlen.`);
return;
}
if (!participant1 || !participant2) {
recordDebugStep("blocked", "error", {
reasonCode: "participant_mapping_incomplete",
participant1Id: normalizeText(match.player1Id || ""),
participant2Id: normalizeText(match.player2Id || ""),
});
storeMatchStartDebugSessionIfEnabled(debugSession, "blocked", {
summary: {
reasonCode: "participant_mapping_incomplete",
message: "Teilnehmerzuordnung im Match ist unvollständig.",
},
});
scheduleDebugPersist();
setNotice("error", "Teilnehmerzuordnung im Match ist unvollst\u00e4ndig.");
return;
}
const initialLobbyPayload = buildLobbyCreatePayload(tournament);
if (debugSession) {
debugSession.context.initialPayload = cloneSerializable(initialLobbyPayload) || {};
}
let flowResult = null;
state.apiAutomation.startingMatchId = match.id;
renderShell();
try {
flowResult = await executeMatchStartApiFlow(
{
matchId: match.id,
token,
boardId,
participant1Name: participant1.name,
participant2Name: participant2.name,
lobbyPayload: initialLobbyPayload,
},
{
createLobby,
addLobbyPlayer,
startLobby,
deleteLobby,
extractErrorText: (error) => normalizeText(error?.message || apiBodyToErrorText(error?.body) || ""),
onStep: (entry) => {
recordDebugStep(entry.step, entry.status, entry.details);
},
},
);
if (!flowResult.ok) {
throw flowResult.error || new Error("Matchstart-Flow fehlgeschlagen.");
}
if (flowResult.usedBullModeFallback) {
logWarn("api", "Lobby create required bullMode fallback 25/50.", {
matchId: match.id,
lobbyId: flowResult.lobbyId,
payload: flowResult.effectivePayload,
});
}
const now = nowIso();
auto.provider = API_PROVIDER;
auto.lobbyId = flowResult.lobbyId;
auto.status = "started";
auto.startedAt = auto.startedAt || now;
auto.finishedAt = null;
auto.lastSyncAt = now;
auto.lastError = null;
match.updatedAt = now;
tournament.updatedAt = now;
recordDebugStep("persist_store", "pending", {
reason: "before_redirect",
lobbyId: flowResult.lobbyId,
});
recordDebugStep("redirect", "pending", { lobbyId: flowResult.lobbyId });
storeMatchStartDebugSessionIfEnabled(debugSession, "success", {
lobbyId: flowResult.lobbyId,
payload: flowResult.effectivePayload,
cleanup: flowResult.cleanup,
summary: {
reasonCode: "started",
message: "Match gestartet. Weiterleitung ins Match.",
usedBullModeFallback: flowResult.usedBullModeFallback,
boardId,
},
});
try {
await persistStore();
} catch (persistError) {
schedulePersist();
logWarn("storage", "Immediate persist before match redirect failed; scheduled retry.", persistError);
}
renderShell();
setNotice("success", "Match gestartet. Weiterleitung ins Match.");
openMatchPage(flowResult.lobbyId);
} catch (error) {
const message = normalizeText(error?.message || apiBodyToErrorText(error?.body) || "Unbekannter API-Fehler.") || "Unbekannter API-Fehler.";
const now = nowIso();
auto.provider = API_PROVIDER;
auto.lobbyId = flowResult?.cleanup?.ok ? null : (normalizeText(flowResult?.lobbyId || "") || auto.lobbyId || null);
auto.status = "error";
auto.lastError = message;
auto.lastSyncAt = now;
match.updatedAt = now;
tournament.updatedAt = now;
recordDebugStep("match_state_error", "info", {
lobbyId: auto.lobbyId,
message,
});
storeMatchStartDebugSessionIfEnabled(debugSession, "error", {
lobbyId: auto.lobbyId,
payload: flowResult?.effectivePayload || initialLobbyPayload,
cleanup: flowResult?.cleanup,
summary: {
reasonCode: "start_failed",
message,
usedBullModeFallback: Boolean(flowResult?.usedBullModeFallback),
boardId,
},
});
schedulePersist();
renderShell();
setNotice("error", `Matchstart fehlgeschlagen: ${message}`);
if (flowResult?.cleanup?.attempted && flowResult.cleanup.ok) {
logWarn("api", "Ungestartete Lobby wurde nach fehlgeschlagenem Matchstart bereinigt.", {
matchId: match.id,
lobbyId: flowResult.lobbyId,
});
} else if (flowResult?.cleanup?.attempted && !flowResult.cleanup.ok) {
logWarn("api", "Cleanup for failed matchstart lobby did not complete.", {
matchId: match.id,
lobbyId: flowResult.lobbyId,
cleanup: flowResult.cleanup,
});
}
logWarn("api", "Match start failed.", error);
} finally {
state.apiAutomation.startingMatchId = "";
renderShell();
}
}
async function syncPendingApiMatches() {
if (state.apiAutomation.syncing) {
return;
}
if (!state.store.settings.featureFlags.autoLobbyStart) {
return;
}
if (Date.now() < state.apiAutomation.authBackoffUntil) {
return;
}
const tournament = state.store.tournament;
if (!tournament) {
return;
}
const syncTargets = tournament.matches.filter((match) => {
return isApiSyncCandidateMatch(match, true);
});
if (!syncTargets.length) {
return;
}
const token = await resolveAuthToken();
if (!token) {
state.apiAutomation.authBackoffUntil = Date.now() + API_AUTH_NOTICE_THROTTLE_MS;
if (shouldShowAuthNotice()) {
setNotice("error", "Auto-Sync pausiert: kein Auth-Token gefunden. Bitte neu einloggen.");
}
return;
}
state.apiAutomation.syncing = true;
let hasMetaUpdates = false;
try {
for (const match of syncTargets) {
const syncOutcome = await syncApiMatchResult(tournament, match, token, {
notifyErrors: false,
notifyNotReady: false,
includeErrorRetry: true,
trigger: "background",
});
if (syncOutcome.authError) {
state.apiAutomation.authBackoffUntil = Date.now() + API_AUTH_NOTICE_THROTTLE_MS;
if (shouldShowAuthNotice()) {
setNotice("error", "Auto-Sync pausiert: Auth abgelaufen. Bitte neu einloggen.");
}
logWarn("api", "Auto-sync auth error.");
return;
}
if (syncOutcome.updated) {
hasMetaUpdates = true;
}
if (!syncOutcome.ok && syncOutcome.message && !syncOutcome.recoverable) {
logWarn("api", `Auto-sync failed for ${match.id}: ${syncOutcome.message}`);
}
}
} finally {
state.apiAutomation.syncing = false;
if (hasMetaUpdates) {
if (state.store.tournament) {
state.store.tournament.updatedAt = nowIso();
}
schedulePersist();
renderShell();
}
}
}
// Presentation layer: UI rendering and interaction wiring.
// Infrastructure layer: browser integration and Autodarts page coupling.
function isAutoDetectMatchRoute(pathname = location.pathname) {
return /^\/(?:matches|lobbies)\/[^/?#]+/i.test(normalizeText(pathname || ""));
}
function playerLookupMap(tournament) {
const map = new Map();
tournament.participants.forEach((participant) => {
map.set(normalizeLookup(participant.name), participant.id);
});
return map;
}
function extractNameCandidatesFromDom(tournament) {
const map = playerLookupMap(tournament);
const counts = new Map();
const selectors = [
'[data-testid*="player"]',
'[data-testid*="name"]',
'[class*="player"]',
'[class*="name"]',
'[class*="scoreboard"]',
];
selectors.forEach((selector) => {
const nodes = Array.from(document.querySelectorAll(selector)).slice(0, 140);
nodes.forEach((node) => {
const text = normalizeText(node.textContent);
if (!text || text.length > 80) {
return;
}
const lookup = normalizeLookup(text);
map.forEach((participantId, key) => {
if (lookup === key || lookup.includes(key)) {
counts.set(participantId, (counts.get(participantId) || 0) + 1);
}
});
});
});
const sorted = [...counts.entries()].sort((left, right) => right[1] - left[1]);
return sorted.slice(0, 4).map((entry) => entry[0]);
}
function extractWinnerFromDom(tournament) {
const map = playerLookupMap(tournament);
const selectors = [
'[class*="winner"]',
'[data-testid*="winner"]',
'[aria-label*="winner"]',
'[aria-label*="Winner"]',
];
for (const selector of selectors) {
const nodes = Array.from(document.querySelectorAll(selector)).slice(0, 80);
for (const node of nodes) {
const text = normalizeLookup(node.textContent || node.getAttribute("aria-label") || "");
if (!text) {
continue;
}
for (const [nameKey, participantId] of map.entries()) {
if (text.includes(nameKey)) {
return participantId;
}
}
}
}
const fullText = normalizeText(document.body?.innerText || "");
const winnerLine = fullText.match(/(?:winner|gewinner)\s*[:\-]\s*([^\n\r]+)/i);
if (winnerLine) {
const candidate = normalizeLookup(winnerLine[1]);
for (const [nameKey, participantId] of map.entries()) {
if (candidate.includes(nameKey)) {
return participantId;
}
}
}
return null;
}
function extractLegScoreFromDom(bestOfLegs) {
const neededWins = getLegsToWin(bestOfLegs);
const selectors = ['[class*="legs"]', '[data-testid*="legs"]', '[class*="score"]', '[class*="result"]'];
let fallback = { p1: 0, p2: 0 };
for (const selector of selectors) {
const nodes = Array.from(document.querySelectorAll(selector)).slice(0, 120);
for (const node of nodes) {
const text = normalizeText(node.textContent);
if (!text || text.length > 20) {
continue;
}
const match = text.match(/^(\d{1,2})\s*[:\-]\s*(\d{1,2})$/);
if (!match) {
continue;
}
const p1 = clampInt(match[1], 0, 0, 50);
const p2 = clampInt(match[2], 0, 0, 50);
fallback = { p1, p2 };
if (p1 === neededWins || p2 === neededWins) {
return fallback;
}
}
}
return fallback;
}
function scanForAutoResult() {
if (!isAutoDetectMatchRoute()) {
return;
}
const tournament = state.store.tournament;
if (!tournament) {
return;
}
const now = Date.now();
if (now - state.autoDetect.lastScanAt < 900) {
return;
}
state.autoDetect.lastScanAt = now;
const winnerId = extractWinnerFromDom(tournament);
if (!winnerId) {
return;
}
const nameCandidates = extractNameCandidatesFromDom(tournament);
const candidateIds = nameCandidates.filter((id, index, array) => array.indexOf(id) === index).slice(0, 3);
if (!candidateIds.length) {
return;
}
let targetMatch = null;
for (let i = 0; i < candidateIds.length; i += 1) {
for (let j = i + 1; j < candidateIds.length; j += 1) {
const match = getOpenMatchByPlayers(tournament, candidateIds[i], candidateIds[j]);
if (match && (winnerId === candidateIds[i] || winnerId === candidateIds[j])) {
if (targetMatch) {
logDebug("autodetect", "Multiple possible matches found, skipping auto close.");
return;
}
targetMatch = match;
}
}
}
if (!targetMatch) {
const openWithWinner = tournament.matches.filter((match) => (
match.status === STATUS_PENDING
&& match.player1Id
&& match.player2Id
&& (match.player1Id === winnerId || match.player2Id === winnerId)
));
if (openWithWinner.length === 1) {
targetMatch = openWithWinner[0];
}
}
if (!targetMatch) {
return;
}
const legs = extractLegScoreFromDom(tournament.bestOfLegs);
const fingerprint = `${targetMatch.id}|${winnerId}|${legs.p1}:${legs.p2}|${location.pathname}`;
if (state.autoDetect.lastFingerprint === fingerprint) {
return;
}
state.autoDetect.lastFingerprint = fingerprint;
const result = updateMatchResult(targetMatch.id, winnerId, legs, "auto");
if (result.ok) {
setNotice("info", `Auto-Ergebnis erkannt: ${participantNameById(tournament, winnerId)} (${targetMatch.id})`, 2600);
logDebug("autodetect", "Auto result applied.", { matchId: targetMatch.id, winnerId, legs });
}
}
function queueAutoScan() {
if (state.autoDetect.queued) {
return;
}
state.autoDetect.queued = true;
requestAnimationFrame(() => {
state.autoDetect.queued = false;
scanForAutoResult();
});
}
function startAutoDetectionObserver() {
if (state.autoDetect.observer) {
state.autoDetect.observer.disconnect();
state.autoDetect.observer = null;
}
const root = document.body || document.documentElement;
if (!root) {
return;
}
const observer = new MutationObserver((mutations) => {
const relevant = mutations.some((mutation) => mutation.type === "childList" || mutation.type === "characterData");
if (relevant) {
queueAutoScan();
}
});
observer.observe(root, {
childList: true,
subtree: true,
characterData: true,
});
state.autoDetect.observer = observer;
addObserver(observer);
}
// History import and lobby-route helpers.
const HISTORY_IMPORT_CONFIRMATION_TTL_MS = 45000;
function removeHistoryImportButton() {
const nodes = Array.from(document.querySelectorAll("[data-ata-history-import-root='1']"));
nodes.forEach((node) => {
if (node instanceof HTMLElement) {
node.remove();
}
});
state.matchReturnShortcut.pendingConfirmationByLobby = {};
}
function isHistoryMatchRoute(pathname = location.pathname) {
return /^\/history\/matches\/[^/?#]+/i.test(normalizeText(pathname || ""));
}
function getHistoryRouteLobbyId(pathname = location.pathname) {
if (!isHistoryMatchRoute(pathname)) {
return "";
}
return getRouteLobbyId(pathname);
}
function isLobbySyncing(lobbyId) {
const targetLobbyId = normalizeText(lobbyId || "");
if (!targetLobbyId) {
return false;
}
const map = state.matchReturnShortcut.inlineSyncingByLobby || {};
return Boolean(map[targetLobbyId]);
}
function setLobbySyncing(lobbyId, syncing) {
const targetLobbyId = normalizeText(lobbyId || "");
if (!targetLobbyId) {
return;
}
if (!state.matchReturnShortcut.inlineSyncingByLobby || typeof state.matchReturnShortcut.inlineSyncingByLobby !== "object") {
state.matchReturnShortcut.inlineSyncingByLobby = {};
}
if (syncing) {
state.matchReturnShortcut.inlineSyncingByLobby[targetLobbyId] = true;
} else {
delete state.matchReturnShortcut.inlineSyncingByLobby[targetLobbyId];
}
state.matchReturnShortcut.syncing = Object.keys(state.matchReturnShortcut.inlineSyncingByLobby).length > 0;
}
function setHistoryInlineOutcome(lobbyId, type, message) {
const targetLobbyId = normalizeText(lobbyId || "");
if (!targetLobbyId) {
return;
}
if (!state.matchReturnShortcut.inlineOutcomeByLobby || typeof state.matchReturnShortcut.inlineOutcomeByLobby !== "object") {
state.matchReturnShortcut.inlineOutcomeByLobby = {};
}
const normalizedType = normalizeText(type || "info");
const normalizedMessage = normalizeText(message || "");
if (!normalizedMessage) {
delete state.matchReturnShortcut.inlineOutcomeByLobby[targetLobbyId];
return;
}
state.matchReturnShortcut.inlineOutcomeByLobby[targetLobbyId] = {
type: normalizedType || "info",
message: normalizedMessage,
updatedAt: Date.now(),
};
}
function getHistoryInlineOutcome(lobbyId) {
const targetLobbyId = normalizeText(lobbyId || "");
if (!targetLobbyId) {
return null;
}
const map = state.matchReturnShortcut.inlineOutcomeByLobby || {};
const entry = map[targetLobbyId];
if (!entry || !normalizeText(entry.message || "")) {
return null;
}
return entry;
}
function ensurePendingHistoryConfirmationMap() {
if (!state.matchReturnShortcut.pendingConfirmationByLobby || typeof state.matchReturnShortcut.pendingConfirmationByLobby !== "object") {
state.matchReturnShortcut.pendingConfirmationByLobby = {};
}
return state.matchReturnShortcut.pendingConfirmationByLobby;
}
function clearPendingHistoryConfirmation(lobbyId) {
const targetLobbyId = normalizeText(lobbyId || "");
if (!targetLobbyId) {
return;
}
const map = ensurePendingHistoryConfirmationMap();
if (map[targetLobbyId]) {
delete map[targetLobbyId];
}
}
function setPendingHistoryConfirmation(lobbyId, pending) {
const targetLobbyId = normalizeText(lobbyId || "");
if (!targetLobbyId) {
return;
}
const map = ensurePendingHistoryConfirmationMap();
if (!pending || typeof pending !== "object") {
delete map[targetLobbyId];
return;
}
map[targetLobbyId] = pending;
}
function getPendingHistoryConfirmation(lobbyId) {
const targetLobbyId = normalizeText(lobbyId || "");
if (!targetLobbyId) {
return null;
}
const map = ensurePendingHistoryConfirmationMap();
const pending = map[targetLobbyId];
if (!pending || typeof pending !== "object") {
return null;
}
const expiresAt = Number(pending.expiresAt || 0);
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
delete map[targetLobbyId];
return null;
}
return pending;
}
function buildHistoryImportConfirmationSignature(payload) {
const parts = [
normalizeText(payload?.tournamentId || ""),
normalizeText(payload?.lobbyId || ""),
normalizeText(payload?.matchId || ""),
normalizeText(payload?.winnerId || ""),
clampInt(payload?.rawP1, 0, 0, 99),
clampInt(payload?.rawP2, 0, 0, 99),
clampInt(payload?.normalizedP1, 0, 0, 99),
clampInt(payload?.normalizedP2, 0, 0, 99),
clampInt(payload?.bestOfLegs, 0, 1, 99),
];
const source = parts.join("|");
let hash = 2166136261;
for (let i = 0; i < source.length; i += 1) {
hash ^= source.charCodeAt(i);
hash = Math.imul(hash, 16777619);
hash >>>= 0;
}
return `history-confirm-${hash.toString(16)}`;
}
function formatHistoryLegsText(legs) {
return `${clampInt(legs?.p1, 0, 0, 99)}:${clampInt(legs?.p2, 0, 0, 99)}`;
}
function cleanupStaleHistoryImportButtons(activeLobbyId = "") {
const nodes = Array.from(document.querySelectorAll("[data-ata-history-import-root='1']"));
nodes.forEach((node) => {
if (!(node instanceof HTMLElement)) {
return;
}
const nodeLobbyId = normalizeText(node.getAttribute("data-lobby-id") || "");
if (!activeLobbyId || nodeLobbyId !== activeLobbyId) {
node.remove();
if (nodeLobbyId && state.matchReturnShortcut.inlineOutcomeByLobby?.[nodeLobbyId]) {
delete state.matchReturnShortcut.inlineOutcomeByLobby[nodeLobbyId];
}
clearPendingHistoryConfirmation(nodeLobbyId);
}
});
}
function findHistoryImportHost(lobbyId) {
const targetLobbyId = normalizeText(lobbyId || "");
if (!targetLobbyId) {
return null;
}
const routeLinks = Array.from(document.querySelectorAll(`a[href^="/history/matches/${targetLobbyId}"]`))
.filter((link) => link instanceof HTMLAnchorElement);
if (!routeLinks.length) {
return {
card: null,
table: null,
reasonCode: "history_host_not_found",
message: "Kein eindeutiger Statistik-Host für diese Lobby auf der History-Seite gefunden.",
};
}
const cards = [];
const seenCards = new Set();
routeLinks.forEach((link) => {
const card = link.closest(".chakra-card, [class*='chakra-card'], article, section, [class*='card']");
if (!(card instanceof HTMLElement)) {
return;
}
if (seenCards.has(card)) {
return;
}
seenCards.add(card);
cards.push(card);
});
if (!cards.length) {
return {
card: null,
table: null,
reasonCode: "history_host_not_found",
message: "Statistik-Host konnte nicht auf einen Kartenbereich zugeordnet werden.",
};
}
if (cards.length > 1) {
return {
card: cards[0],
table: null,
reasonCode: "history_host_ambiguous",
message: "Mehrdeutiger Statistik-Host: Mehrere passende Bereiche auf der Seite gefunden.",
};
}
const card = cards[0];
const tables = Array.from(card.querySelectorAll("table")).filter((entry) => entry instanceof HTMLTableElement);
if (!tables.length) {
return {
card,
table: null,
reasonCode: "history_table_missing",
message: "Im erkannten Statistik-Bereich wurde keine eindeutige Tabelle gefunden.",
};
}
if (tables.length > 1) {
return {
card,
table: null,
reasonCode: "history_table_ambiguous",
message: "Im Statistik-Bereich wurden mehrere Tabellen gefunden. Import wurde aus Sicherheitsgründen gestoppt.",
};
}
return {
card,
table: tables[0],
reasonCode: "ok",
message: "",
};
}
function ensureHistoryImportRoot(host, lobbyId) {
const targetLobbyId = normalizeText(lobbyId || "");
if (!(host instanceof HTMLElement) || !targetLobbyId) {
return null;
}
let root = host.querySelector(`[data-ata-history-import-root='1'][data-lobby-id='${targetLobbyId}']`);
if (root instanceof HTMLElement) {
return root;
}
root = document.createElement("div");
root.setAttribute("data-ata-history-import-root", "1");
root.setAttribute("data-lobby-id", targetLobbyId);
if (host.firstChild) {
host.insertBefore(root, host.firstChild);
} else {
host.appendChild(root);
}
return root;
}
function extractHistoryHeaderName(cell) {
if (!(cell instanceof HTMLElement)) {
return "";
}
const namedNode = cell.querySelector(".ad-ext-player-name p, .ad-ext-player-name, p, span");
if (namedNode instanceof HTMLElement) {
return normalizeText(namedNode.textContent || "");
}
const fallbackText = normalizeText(cell.textContent || "");
if (!fallbackText) {
return "";
}
const parts = fallbackText.split(/\s+/).filter(Boolean);
return normalizeText(parts[parts.length - 1] || fallbackText);
}
function parseHistoryStatsTable(table) {
if (!(table instanceof HTMLTableElement)) {
return null;
}
const headerCells = Array.from(table.querySelectorAll("thead tr td"));
if (headerCells.length < 2) {
return null;
}
const p1Cell = headerCells[0];
const p2Cell = headerCells[1];
const p1Name = extractHistoryHeaderName(p1Cell);
const p2Name = extractHistoryHeaderName(p2Cell);
if (!p1Name || !p2Name) {
return null;
}
const p1HasTrophy = Boolean(p1Cell?.querySelector("svg[data-icon='trophy'], [data-icon='trophy'], .fa-trophy"));
const p2HasTrophy = Boolean(p2Cell?.querySelector("svg[data-icon='trophy'], [data-icon='trophy'], .fa-trophy"));
let p1Legs = null;
let p2Legs = null;
const rows = Array.from(table.querySelectorAll("tbody tr"));
rows.forEach((row) => {
if (!(row instanceof HTMLTableRowElement)) {
return;
}
const cells = Array.from(row.querySelectorAll("td"));
if (cells.length < 3) {
return;
}
const label = normalizeLookup(cells[0].textContent || "");
const isLegsRow = label.includes("gewonnene legs")
|| label.includes("legs won")
|| label === "legs";
if (!isLegsRow) {
return;
}
p1Legs = clampInt(cells[1].textContent, 0, 0, 50);
p2Legs = clampInt(cells[2].textContent, 0, 0, 50);
});
if (!Number.isInteger(p1Legs) || !Number.isInteger(p2Legs)) {
return null;
}
let winnerIndex = -1;
if (p1Legs !== p2Legs) {
winnerIndex = p1Legs > p2Legs ? 0 : 1;
} else if (p1HasTrophy !== p2HasTrophy) {
winnerIndex = p1HasTrophy ? 0 : 1;
}
return {
p1Name,
p2Name,
p1Legs,
p2Legs,
winnerIndex,
};
}
function getParsedHistoryWinnerName(parsed) {
if (!parsed) {
return "";
}
if (parsed.winnerIndex === 0) {
return parsed.p1Name;
}
if (parsed.winnerIndex === 1) {
return parsed.p2Name;
}
if (Number.isInteger(parsed.p1Legs) && Number.isInteger(parsed.p2Legs) && parsed.p1Legs !== parsed.p2Legs) {
return parsed.p1Legs > parsed.p2Legs ? parsed.p1Name : parsed.p2Name;
}
return "";
}
function scoreParticipantNameMatch(participantName, tableName) {
const participantLookup = normalizeLookup(participantName || "");
const tableLookup = normalizeLookup(tableName || "");
if (!participantLookup || !tableLookup) {
return 0;
}
if (participantLookup === tableLookup) {
return 400;
}
const participantToken = normalizeToken(participantLookup);
const tableToken = normalizeToken(tableLookup);
if (participantToken && tableToken && participantToken === tableToken) {
return 360;
}
const participantWords = participantLookup.split(/\s+/).filter(Boolean);
const tableWords = tableLookup.split(/\s+/).filter(Boolean);
if (participantWords.includes(tableLookup) || tableWords.includes(participantLookup)) {
return 300;
}
if (
participantLookup.startsWith(`${tableLookup} `)
|| participantLookup.endsWith(` ${tableLookup}`)
|| tableLookup.startsWith(`${participantLookup} `)
|| tableLookup.endsWith(` ${participantLookup}`)
) {
return 280;
}
if (participantToken && tableToken && participantToken.includes(tableToken)) {
return 240;
}
if (participantToken && tableToken && tableToken.includes(participantToken)) {
return 200;
}
if (participantLookup.includes(tableLookup)) {
return 170;
}
if (tableLookup.includes(participantLookup)) {
return 140;
}
return 0;
}
function participantIdsByName(tournament, name) {
const tableName = normalizeText(name || "");
if (!tableName) {
return [];
}
const scored = (tournament?.participants || [])
.map((participant) => ({
id: participant?.id,
score: scoreParticipantNameMatch(participant?.name || "", tableName),
}))
.filter((entry) => entry.id && entry.score > 0)
.sort((left, right) => right.score - left.score);
if (!scored.length) {
return [];
}
const exact = scored.filter((entry) => entry.score >= 360);
if (exact.length) {
return exact.map((entry) => entry.id);
}
const strong = scored.filter((entry) => entry.score >= 280);
if (strong.length) {
return strong.map((entry) => entry.id);
}
const medium = scored.filter((entry) => entry.score >= 200);
if (medium.length === 1) {
return [medium[0].id];
}
return medium.map((entry) => entry.id);
}
function getOpenMatchCandidatesByParticipantIds(tournament, idA, idB) {
const left = normalizeText(idA || "");
const right = normalizeText(idB || "");
if (!left || !right || left === right) {
return [];
}
const key = new Set([left, right]);
return (tournament?.matches || [])
.filter((match) => {
if (!match || match.status !== STATUS_PENDING || !match.player1Id || !match.player2Id) {
return false;
}
const set = new Set([normalizeText(match.player1Id), normalizeText(match.player2Id)]);
return key.size === set.size && [...key].every((entry) => set.has(entry));
})
.sort((a, b) => {
if (a.round !== b.round) {
return a.round - b.round;
}
return a.number - b.number;
});
}
function resolveTableToMatchOrder(tournament, match, parsed) {
const matchP1Name = participantNameById(tournament, match?.player1Id);
const matchP2Name = participantNameById(tournament, match?.player2Id);
const directScore = scoreParticipantNameMatch(matchP1Name, parsed?.p1Name)
+ scoreParticipantNameMatch(matchP2Name, parsed?.p2Name);
const swappedScore = scoreParticipantNameMatch(matchP1Name, parsed?.p2Name)
+ scoreParticipantNameMatch(matchP2Name, parsed?.p1Name);
if (swappedScore > directScore) {
return false;
}
if (directScore > swappedScore) {
return true;
}
const parsedP1Lookup = normalizeLookup(parsed?.p1Name || "");
const matchP1Lookup = normalizeLookup(matchP1Name || "");
if (parsedP1Lookup && matchP1Lookup && parsedP1Lookup === matchP1Lookup) {
return true;
}
return true;
}
function normalizeHistoryLegsForTournament(tournament, match, winnerId, legsRaw) {
const legsToWin = getLegsToWin(tournament?.bestOfLegs);
const winnerIsP1 = winnerId === match?.player1Id;
let p1 = clampInt(legsRaw?.p1, 0, 0, 99);
let p2 = clampInt(legsRaw?.p2, 0, 0, 99);
let winnerLegs = winnerIsP1 ? p1 : p2;
let loserLegs = winnerIsP1 ? p2 : p1;
let adjusted = false;
if (winnerLegs <= loserLegs) {
winnerLegs = loserLegs + 1;
adjusted = true;
}
if (winnerLegs !== legsToWin) {
winnerLegs = legsToWin;
adjusted = true;
}
loserLegs = clampInt(loserLegs, 0, 0, Math.max(0, legsToWin - 1));
if (loserLegs >= winnerLegs) {
loserLegs = Math.max(0, winnerLegs - 1);
adjusted = true;
}
p1 = winnerIsP1 ? winnerLegs : loserLegs;
p2 = winnerIsP1 ? loserLegs : winnerLegs;
return {
legs: { p1, p2 },
adjusted,
legsToWin,
};
}
function findHistoryImportMatchCandidates(tournament, lobbyId, parsed) {
const targetLobbyId = normalizeText(lobbyId || "");
const linkedByLobby = findTournamentMatchByLobbyId(tournament, targetLobbyId, false);
const seenMatches = new Set();
const matchCandidates = [];
const pushMatch = (match) => {
if (!match?.id || seenMatches.has(match.id)) {
return;
}
seenMatches.add(match.id);
matchCandidates.push(match);
};
if (linkedByLobby?.status === STATUS_PENDING && linkedByLobby.player1Id && linkedByLobby.player2Id) {
pushMatch(linkedByLobby);
}
const p1Ids = participantIdsByName(tournament, parsed?.p1Name);
const p2Ids = participantIdsByName(tournament, parsed?.p2Name);
p1Ids.forEach((p1Id) => {
p2Ids.forEach((p2Id) => {
getOpenMatchCandidatesByParticipantIds(tournament, p1Id, p2Id).forEach((match) => {
pushMatch(match);
});
});
});
return {
linkedByLobby: linkedByLobby && linkedByLobby.status === STATUS_PENDING ? linkedByLobby : null,
matchCandidates,
};
}
function importHistoryStatsTableResult(lobbyId, hostInfo, options = {}) {
const targetLobbyId = normalizeText(lobbyId || "");
const tournament = state.store.tournament;
if (!tournament || !targetLobbyId) {
return null;
}
const hostReasonCode = normalizeText(hostInfo?.reasonCode || "");
if (hostReasonCode && hostReasonCode !== "ok") {
clearPendingHistoryConfirmation(targetLobbyId);
return {
ok: false,
completed: false,
reasonCode: hostReasonCode,
message: normalizeText(hostInfo?.message || "Statistik-Host ist nicht eindeutig zuordenbar."),
};
}
const parsed = parseHistoryStatsTable(hostInfo?.table);
if (!parsed) {
clearPendingHistoryConfirmation(targetLobbyId);
logDebug("api", "History table import skipped: stats table not parsable.", {
lobbyId: targetLobbyId,
});
return null;
}
const selection = findHistoryImportMatchCandidates(tournament, targetLobbyId, parsed);
const linkedByLobby = selection.linkedByLobby;
const matchCandidates = selection.matchCandidates;
let match = null;
if (linkedByLobby?.player1Id && linkedByLobby?.player2Id) {
match = linkedByLobby;
} else if (!matchCandidates.length) {
clearPendingHistoryConfirmation(targetLobbyId);
return {
ok: false,
completed: false,
reasonCode: "not_found",
message: "Kein offenes Turnier-Match aus Lobby-ID oder Statistik-Spielern gefunden.",
};
} else if (matchCandidates.length > 1) {
clearPendingHistoryConfirmation(targetLobbyId);
return {
ok: false,
completed: false,
reasonCode: "ambiguous",
message: "Mehrdeutige Zuordnung: mehrere offene Turnier-Matches passen zu diesen Spielern.",
};
} else {
match = matchCandidates[0];
}
const tableMapsDirect = resolveTableToMatchOrder(tournament, match, parsed);
const legsRaw = tableMapsDirect
? { p1: parsed.p1Legs, p2: parsed.p2Legs }
: { p1: parsed.p2Legs, p2: parsed.p1Legs };
let winnerId = "";
if (parsed.winnerIndex === 0) {
winnerId = tableMapsDirect ? match.player1Id : match.player2Id;
} else if (parsed.winnerIndex === 1) {
winnerId = tableMapsDirect ? match.player2Id : match.player1Id;
} else if (legsRaw.p1 !== legsRaw.p2) {
winnerId = legsRaw.p1 > legsRaw.p2 ? match.player1Id : match.player2Id;
}
if (!winnerId) {
clearPendingHistoryConfirmation(targetLobbyId);
return {
ok: false,
completed: false,
reasonCode: "error",
message: "Sieger konnte aus der Statistik nicht eindeutig bestimmt werden.",
};
}
const normalizedLegs = normalizeHistoryLegsForTournament(tournament, match, winnerId, legsRaw);
const confirmationSignature = buildHistoryImportConfirmationSignature({
tournamentId: tournament.id,
lobbyId: targetLobbyId,
matchId: match.id,
winnerId,
rawP1: legsRaw.p1,
rawP2: legsRaw.p2,
normalizedP1: normalizedLegs.legs.p1,
normalizedP2: normalizedLegs.legs.p2,
bestOfLegs: tournament.bestOfLegs,
});
const requestedSignature = normalizeText(options?.confirmationSignature || "");
if (normalizedLegs.adjusted) {
if (!requestedSignature) {
const pending = {
signature: confirmationSignature,
expiresAt: Date.now() + HISTORY_IMPORT_CONFIRMATION_TTL_MS,
matchId: match.id,
winnerId,
legsRaw: { p1: legsRaw.p1, p2: legsRaw.p2 },
normalizedLegs: { p1: normalizedLegs.legs.p1, p2: normalizedLegs.legs.p2 },
legsToWin: normalizedLegs.legsToWin,
};
setPendingHistoryConfirmation(targetLobbyId, pending);
return {
ok: true,
completed: false,
reasonCode: "requires_confirmation",
requiresConfirmation: true,
message: `Leg-Abweichung erkannt: Statistik ${formatHistoryLegsText(pending.legsRaw)} -> Turnier First to ${normalizedLegs.legsToWin} erwartet ${formatHistoryLegsText(pending.normalizedLegs)}. Bitte explizit bestätigen.`,
confirm: {
signature: pending.signature,
expiresAt: pending.expiresAt,
ttlMs: HISTORY_IMPORT_CONFIRMATION_TTL_MS,
matchId: pending.matchId,
winnerId: pending.winnerId,
legsRaw: pending.legsRaw,
normalizedLegs: pending.normalizedLegs,
legsToWin: pending.legsToWin,
},
};
}
const pendingMap = ensurePendingHistoryConfirmationMap();
const stalePending = pendingMap[targetLobbyId];
const staleExpiresAt = Number(stalePending?.expiresAt || 0);
const staleExpired = Boolean(stalePending && Number.isFinite(staleExpiresAt) && staleExpiresAt <= Date.now());
const pending = getPendingHistoryConfirmation(targetLobbyId);
if (!pending || pending.signature !== requestedSignature) {
if (staleExpired) {
delete pendingMap[targetLobbyId];
return {
ok: false,
completed: false,
reasonCode: "confirmation_expired",
message: "Bestätigung ist abgelaufen. Bitte den Import erneut starten.",
};
}
return {
ok: false,
completed: false,
reasonCode: "confirmation_invalid",
message: "Bestätigung ist ungültig. Bitte den Import erneut starten.",
};
}
if (requestedSignature !== confirmationSignature) {
setPendingHistoryConfirmation(targetLobbyId, null);
return {
ok: false,
completed: false,
reasonCode: "confirmation_invalid",
message: "Bestätigung passt nicht mehr zur aktuellen Statistik. Bitte erneut bestätigen.",
};
}
}
const result = updateMatchResult(match.id, winnerId, normalizedLegs.legs, "auto");
if (!result.ok) {
clearPendingHistoryConfirmation(targetLobbyId);
return {
ok: false,
completed: false,
reasonCode: "error",
message: result.message || "Ergebnis konnte nicht aus der Statistik gespeichert werden.",
};
}
clearPendingHistoryConfirmation(targetLobbyId);
const updatedMatch = findMatch(tournament, match.id);
if (updatedMatch) {
const auto = ensureMatchAutoMeta(updatedMatch);
const now = nowIso();
auto.provider = API_PROVIDER;
auto.lobbyId = auto.lobbyId || targetLobbyId;
auto.status = "completed";
auto.finishedAt = auto.finishedAt || now;
auto.lastSyncAt = now;
auto.lastError = null;
updatedMatch.updatedAt = now;
tournament.updatedAt = now;
schedulePersist();
}
logDebug("api", "History table result imported.", {
lobbyId: targetLobbyId,
matchId: match.id,
winnerId,
legs: normalizedLegs.legs,
adjustedLegs: normalizedLegs.adjusted,
linkedByLobby: Boolean(linkedByLobby?.id),
});
const successMessage = normalizedLegs.adjusted
? `Ergebnis übernommen. Legs wurden nach bestätigter Abweichung auf First to ${normalizedLegs.legsToWin} gesetzt.`
: "Ergebnis wurde aus der Match-Statistik übernommen.";
return {
ok: true,
completed: true,
reasonCode: "completed",
message: successMessage,
};
}
function renderHistoryImportButton() {
const lobbyId = getHistoryRouteLobbyId();
if (!lobbyId) {
removeHistoryImportButton();
return;
}
const tournament = state.store.tournament;
if (!tournament) {
removeHistoryImportButton();
return;
}
const hostInfo = findHistoryImportHost(lobbyId);
if (!hostInfo) {
removeHistoryImportButton();
return;
}
if (!hostInfo.card) {
removeHistoryImportButton();
return;
}
cleanupStaleHistoryImportButtons(lobbyId);
const root = ensureHistoryImportRoot(hostInfo.card, lobbyId);
if (!(root instanceof HTMLElement)) {
return;
}
if (hostInfo.table instanceof HTMLElement && root.nextSibling !== hostInfo.table) {
hostInfo.card.insertBefore(root, hostInfo.table);
}
const linkedMatchAny = findTournamentMatchByLobbyId(tournament, lobbyId, true);
const auto = linkedMatchAny ? ensureMatchAutoMeta(linkedMatchAny) : null;
const autoEnabled = Boolean(state.store.settings.featureFlags.autoLobbyStart);
const isSyncing = isLobbySyncing(lobbyId);
const isAlreadyCompleted = linkedMatchAny?.status === STATUS_COMPLETED;
const parsedStats = parseHistoryStatsTable(hostInfo.table);
const hostReasonCode = normalizeText(hostInfo.reasonCode || "");
const hostBlocked = Boolean(hostReasonCode && hostReasonCode !== "ok");
const hostMessage = normalizeText(hostInfo.message || "");
const pendingConfirmation = getPendingHistoryConfirmation(lobbyId);
const parsedWinnerName = getParsedHistoryWinnerName(parsedStats);
const parsedScoreText = parsedStats
? `${parsedStats.p1Name} ${parsedStats.p1Legs}:${parsedStats.p2Legs} ${parsedStats.p2Name}`
: "";
const inlineOutcome = getHistoryInlineOutcome(lobbyId);
let statusText = "";
if (isAlreadyCompleted) {
statusText = "Ergebnis bereits im Turnier gespeichert.";
} else if (hostBlocked) {
statusText = hostMessage || "Statistik-Bereich ist nicht eindeutig. Import ist gesperrt.";
} else if (pendingConfirmation) {
statusText = `Leg-Abweichung erkannt. Bitte Übernahme ${formatHistoryLegsText(pendingConfirmation.normalizedLegs)} explizit bestätigen.`;
} else if (!autoEnabled) {
statusText = "Auto-Lobby ist deaktiviert. Aktivieren Sie die Funktion im Tab Einstellungen.";
} else if (!parsedStats) {
statusText = "Statistik konnte nicht vollständig gelesen werden. Beim Klick wird API-Fallback genutzt.";
} else if (parsedWinnerName) {
statusText = `Import bereit. Sieger laut Statistik: ${parsedWinnerName}.`;
} else if (linkedMatchAny && auto?.status === "error") {
statusText = `Letzter Sync-Fehler: ${normalizeText(auto.lastError || "Unbekannt") || "Unbekannt"}`;
} else if (linkedMatchAny && auto?.status === "started") {
statusText = "Match verknüpft. Ergebnis kann jetzt übernommen werden.";
} else {
statusText = "Kein direkt verknüpftes Match gefunden. Ergebnisübernahme versucht Zuordnung über die Statistik.";
}
const primaryLabel = isAlreadyCompleted
? "Turnierassistent öffnen"
: (isSyncing ? "\u00dcbernehme..." : (pendingConfirmation ? "Statistik erneut prüfen" : "Ergebnis aus Statistik \u00fcbernehmen & Turnier \u00f6ffnen"));
const disabledAttr = isSyncing || hostBlocked || (!autoEnabled && !isAlreadyCompleted) ? "disabled" : "";
const confirmDisabledAttr = isSyncing || hostBlocked || !autoEnabled || isAlreadyCompleted || !pendingConfirmation ? "disabled" : "";
const confirmLabel = isSyncing ? "Bestätigung läuft..." : "Leg-Abweichung bestätigen & speichern";
const outcomeType = normalizeText(inlineOutcome?.type || "info");
const outcomeMessage = normalizeText(inlineOutcome?.message || "");
const outcomeColor = outcomeType === "success"
? "#d8ffe7"
: outcomeType === "error"
? "#ffd9dc"
: "#dbe8ff";
const outcomeBg = outcomeType === "success"
? "rgba(73, 205, 138, 0.16)"
: outcomeType === "error"
? "rgba(214, 74, 105, 0.18)"
: "rgba(119, 167, 255, 0.14)";
const outcomeBorder = outcomeType === "success"
? "rgba(95, 220, 154, 0.5)"
: outcomeType === "error"
? "rgba(245, 123, 143, 0.52)"
: "rgba(142, 188, 255, 0.48)";
const statusTextHtml = renderDocLinkableMessage(statusText, {
tagName: "div",
className: "ata-history-import-copy",
attributes: 'style="font-size:13px;line-height:1.45;color:rgba(240,246,255,0.95);margin-bottom:8px;"',
});
const outcomeMessageHtml = outcomeMessage
? renderDocLinkableMessage(outcomeMessage, {
tagName: "div",
className: "ata-history-import-outcome",
attributes: `style="font-size:12px;line-height:1.4;color:${outcomeColor};background:${outcomeBg};border:1px solid ${outcomeBorder};padding:7px 9px;border-radius:8px;margin-bottom:10px;"`,
})
: "";
root.innerHTML = `
xLokales Turnier
Match-Import
${statusTextHtml}
${parsedScoreText ? `
Statistik: ${escapeHtml(parsedScoreText)}
` : ""}
${pendingConfirmation ? `
Bestätigung erforderlich bis ${escapeHtml(new Date(pendingConfirmation.expiresAt).toLocaleTimeString("de-DE"))}: ${escapeHtml(formatHistoryLegsText(pendingConfirmation.legsRaw))} -> ${escapeHtml(formatHistoryLegsText(pendingConfirmation.normalizedLegs))}
` : ""}
${outcomeMessageHtml}
${escapeHtml(primaryLabel)}
${pendingConfirmation ? `
${escapeHtml(confirmLabel)} ` : ""}
`;
const syncButton = root.querySelector("[data-action='ata-history-sync']");
if (syncButton instanceof HTMLButtonElement) {
if (isSyncing || hostBlocked || (!autoEnabled && !isAlreadyCompleted)) {
syncButton.onclick = null;
} else if (isAlreadyCompleted) {
syncButton.onclick = () => {
openAssistantMatchesTab();
};
} else {
syncButton.onclick = () => {
handleHistoryImportClick(lobbyId).catch((error) => {
logWarn("api", "Inline history import action failed.", error);
});
};
}
}
const confirmButton = root.querySelector("[data-action='ata-history-confirm-sync']");
if (confirmButton instanceof HTMLButtonElement) {
if (confirmDisabledAttr) {
confirmButton.onclick = null;
} else {
confirmButton.onclick = () => {
handleHistoryImportConfirmClick(lobbyId, pendingConfirmation?.signature || "").catch((error) => {
logWarn("api", "Inline history confirmation action failed.", error);
});
};
}
}
}
function openAssistantMatchesTab() {
state.activeTab = "matches";
state.store.ui.activeTab = "matches";
schedulePersist();
if (state.drawerOpen) {
renderShell();
return;
}
openDrawer();
}
async function handleLobbySyncAndOpen(lobbyId, trigger = "manual", options = {}) {
const targetLobbyId = normalizeText(lobbyId || "");
if (!targetLobbyId || isLobbySyncing(targetLobbyId)) {
return;
}
setHistoryInlineOutcome(targetLobbyId, "info", "Übernehme Ergebnis...");
setLobbySyncing(targetLobbyId, true);
renderHistoryImportButton();
try {
let syncOutcome = null;
if (trigger === "inline-history" || trigger === "inline-history-confirm") {
const hostInfo = findHistoryImportHost(targetLobbyId);
syncOutcome = importHistoryStatsTableResult(targetLobbyId, hostInfo, {
confirmationSignature: normalizeText(options?.confirmationSignature || ""),
});
}
if (!syncOutcome) {
syncOutcome = await syncResultForLobbyId(targetLobbyId, {
notifyErrors: true,
notifyNotReady: true,
trigger,
});
}
if (syncOutcome.reasonCode === "completed" || syncOutcome.completed) {
openAssistantMatchesTab();
setHistoryInlineOutcome(targetLobbyId, "success", syncOutcome.message || "Ergebnis wurde übernommen.");
const alreadyStored = normalizeText(syncOutcome.message || "").includes("bereits");
if (alreadyStored) {
setNotice("info", syncOutcome.message || "Ergebnis war bereits im Turnier gespeichert.", 2600);
} else {
setNotice("success", syncOutcome.message || "Ergebnis wurde in xLokales Turnier \u00fcbernommen.", 2600);
}
} else if (syncOutcome.reasonCode === "requires_confirmation") {
setHistoryInlineOutcome(targetLobbyId, "info", syncOutcome.message || "Explizite Bestätigung erforderlich.");
setNotice("info", syncOutcome.message || "Explizite Bestätigung erforderlich.", 4200);
} else if (!syncOutcome.ok && syncOutcome.message) {
const reasonCode = normalizeText(syncOutcome.reasonCode || "");
const isError = reasonCode === "ambiguous"
|| reasonCode === "confirmation_invalid"
|| reasonCode === "confirmation_expired";
setHistoryInlineOutcome(targetLobbyId, isError ? "error" : "info", syncOutcome.message);
const noticeType = isError ? "error" : "info";
setNotice(noticeType, syncOutcome.message, 3200);
} else if (syncOutcome.ok && !syncOutcome.completed) {
setHistoryInlineOutcome(targetLobbyId, "info", "Noch kein finales Ergebnis verfügbar.");
setNotice("info", "Noch kein finales Ergebnis verf\u00fcgbar. Match l\u00e4uft ggf. noch.", 2600);
}
} catch (error) {
logWarn("api", "Manual shortcut sync failed.", error);
setHistoryInlineOutcome(targetLobbyId, "error", "Ergebnisübernahme fehlgeschlagen. Bitte erneut versuchen.");
setNotice("error", "Ergebnis\u00fcbernahme fehlgeschlagen. Bitte sp\u00e4ter erneut versuchen.");
} finally {
setLobbySyncing(targetLobbyId, false);
renderHistoryImportButton();
}
}
async function handleHistoryImportClick(lobbyId) {
return handleLobbySyncAndOpen(lobbyId, "inline-history");
}
async function handleHistoryImportConfirmClick(lobbyId, confirmationSignature) {
return handleLobbySyncAndOpen(lobbyId, "inline-history-confirm", {
confirmationSignature,
});
}
function onRouteChange() {
const current = routeKey();
if (current === state.routeKey) {
return;
}
state.routeKey = current;
logDebug("route", `Route changed to ${current}`);
ensureHost();
renderShell();
renderHistoryImportButton();
}
function installRouteHooks() {
if (state.patchedHistory) {
return;
}
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;
window.history.pushState = function patchedPushState(...args) {
const result = originalPushState.apply(this, args);
onRouteChange();
return result;
};
window.history.replaceState = function patchedReplaceState(...args) {
const result = originalReplaceState.apply(this, args);
onRouteChange();
return result;
};
state.patchedHistory = {
pushState: originalPushState,
replaceState: originalReplaceState,
};
addCleanup(() => {
if (state.patchedHistory) {
window.history.pushState = state.patchedHistory.pushState;
window.history.replaceState = state.patchedHistory.replaceState;
state.patchedHistory = null;
}
});
addListener(window, "popstate", onRouteChange, { passive: true });
addListener(window, "hashchange", onRouteChange, { passive: true });
addInterval(() => {
if (!document.getElementById(UI_HOST_ID)) {
ensureHost();
}
onRouteChange();
renderHistoryImportButton();
}, 1000);
}
// Best-effort GitHub version check for direct userscript installs.
function normalizeVersion(value) {
return String(value || "").trim();
}
function normalizeHeaderValue(value) {
return String(value || "").trim();
}
function normalizeValidatorEntry(entry = {}) {
if (!entry || typeof entry !== "object") {
return null;
}
const remoteVersion = normalizeVersion(entry.remoteVersion);
const etag = normalizeHeaderValue(entry.etag);
const lastModified = normalizeHeaderValue(entry.lastModified);
if (!remoteVersion && !etag && !lastModified) {
return null;
}
return {
remoteVersion,
etag,
lastModified,
};
}
function normalizeValidatorsMap(values) {
if (!values || typeof values !== "object") {
return {};
}
return Object.keys(values).reduce((result, sourceUrl) => {
const normalizedSourceUrl = String(sourceUrl || "").trim();
if (!normalizedSourceUrl) {
return result;
}
const normalizedEntry = normalizeValidatorEntry(values[sourceUrl]);
if (!normalizedEntry) {
return result;
}
result[normalizedSourceUrl] = normalizedEntry;
return result;
}, {});
}
function mergeValidatorEntry(validators, sourceUrl, nextEntry) {
const normalizedSourceUrl = String(sourceUrl || "").trim();
const nextValidators = {
...normalizeValidatorsMap(validators),
};
if (!normalizedSourceUrl) {
return nextValidators;
}
const normalizedEntry = normalizeValidatorEntry(nextEntry);
if (!normalizedEntry) {
delete nextValidators[normalizedSourceUrl];
return nextValidators;
}
nextValidators[normalizedSourceUrl] = normalizedEntry;
return nextValidators;
}
function getResponseHeader(response, headerName) {
const normalizedHeaderName = String(headerName || "").trim().toLowerCase();
if (!normalizedHeaderName) {
return "";
}
if (typeof response?.headers?.get === "function") {
return normalizeHeaderValue(response.headers.get(headerName));
}
if (response?.headers && typeof response.headers === "object") {
const matchingKey = Object.keys(response.headers).find((key) => {
return String(key || "").trim().toLowerCase() === normalizedHeaderName;
});
if (matchingKey) {
return normalizeHeaderValue(response.headers[matchingKey]);
}
}
return "";
}
function createBaseUpdateStatus(installedVersion, capable) {
return {
capable: Boolean(capable),
status: "idle",
installedVersion: normalizeVersion(installedVersion),
remoteVersion: "",
available: false,
checkedAt: 0,
sourceUrl: "",
downloadUrl: USERSCRIPT_DOWNLOAD_URL,
error: "",
stale: false,
validators: {},
};
}
function safeParseJson(value) {
if (typeof value !== "string" || !value.trim()) {
return null;
}
try {
return JSON.parse(value);
} catch (_) {
return null;
}
}
function getUpdateStorageRef(windowRef) {
const storageRef = windowRef?.localStorage || null;
if (!storageRef || typeof storageRef.getItem !== "function" || typeof storageRef.setItem !== "function") {
return null;
}
return storageRef;
}
function getUpdateFetchFn(windowRef) {
return typeof windowRef?.fetch === "function" ? windowRef.fetch.bind(windowRef) : null;
}
function parseVersionToken(token) {
const rawToken = String(token || "").trim();
if (!rawToken) {
return { type: "number", value: 0 };
}
if (/^\d+$/.test(rawToken)) {
return { type: "number", value: Number.parseInt(rawToken, 10) };
}
return { type: "string", value: rawToken.toLowerCase() };
}
function compareVersions(leftVersion, rightVersion) {
const leftParts = normalizeVersion(leftVersion).split(/[.-]/);
const rightParts = normalizeVersion(rightVersion).split(/[.-]/);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const leftToken = parseVersionToken(leftParts[index]);
const rightToken = parseVersionToken(rightParts[index]);
if (leftToken.type === rightToken.type) {
if (leftToken.value > rightToken.value) {
return 1;
}
if (leftToken.value < rightToken.value) {
return -1;
}
continue;
}
if (leftToken.type === "number") {
return 1;
}
return -1;
}
return 0;
}
function parseUserscriptVersion(text) {
const match = String(text || "").match(/@version\s+([^\s]+)/i);
return normalizeVersion(match?.[1] || "");
}
function buildCacheBustedUrl(sourceUrl, now = Date.now()) {
const normalizedSourceUrl = String(sourceUrl || "").trim();
if (!normalizedSourceUrl) {
return "";
}
const cacheBustValue = String(Math.max(0, Number(now) || 0));
try {
const parsed = new URL(normalizedSourceUrl);
parsed.searchParams.set(UPDATE_CACHE_BUST_PARAM, cacheBustValue);
return parsed.toString();
} catch (_) {
const separator = normalizedSourceUrl.includes("?") ? "&" : "?";
return `${normalizedSourceUrl}${separator}${UPDATE_CACHE_BUST_PARAM}=${encodeURIComponent(cacheBustValue)}`;
}
}
function createResolvedUpdateStatus({
capable,
installedVersion,
remoteVersion,
checkedAt,
sourceUrl,
error = "",
stale = false,
validators = {},
}) {
const baseStatus = createBaseUpdateStatus(installedVersion, capable);
const normalizedRemoteVersion = normalizeVersion(remoteVersion);
const comparison = normalizedRemoteVersion
? compareVersions(normalizedRemoteVersion, baseStatus.installedVersion)
: 0;
return {
...baseStatus,
status: normalizedRemoteVersion
? (comparison > 0 ? "available" : "current")
: (error ? "error" : "idle"),
remoteVersion: normalizedRemoteVersion,
available: normalizedRemoteVersion ? comparison > 0 : false,
checkedAt: Number(checkedAt) > 0 ? Number(checkedAt) : 0,
sourceUrl: String(sourceUrl || "").trim(),
error: String(error || "").trim(),
stale: Boolean(stale),
validators: normalizeValidatorsMap(validators),
};
}
function readStoredUpdatePayload(storageRef) {
if (!storageRef) {
return null;
}
try {
return safeParseJson(storageRef.getItem(UPDATE_STATUS_STORAGE_KEY));
} catch (_) {
return null;
}
}
function writeStoredUpdatePayload(storageRef, payload) {
if (!storageRef) {
return;
}
try {
storageRef.setItem(UPDATE_STATUS_STORAGE_KEY, JSON.stringify({
remoteVersion: normalizeVersion(payload?.remoteVersion),
checkedAt: Number(payload?.checkedAt) > 0 ? Number(payload.checkedAt) : 0,
sourceUrl: String(payload?.sourceUrl || "").trim(),
validators: normalizeValidatorsMap(payload?.validators),
}));
} catch (_) {
// Ignore storage write failures.
}
}
async function fetchRemoteVersion(fetchFn, options = {}) {
const now = Number(options.now || Date.now());
const validators = normalizeValidatorsMap(options.validators);
const candidateUrls = [USERSCRIPT_UPDATE_URL, USERSCRIPT_DOWNLOAD_URL];
let lastError = null;
let nextValidators = validators;
for (const sourceUrl of candidateUrls) {
const requestUrl = buildCacheBustedUrl(sourceUrl, now);
const cachedValidator = validators[sourceUrl] || null;
const headers = {};
if (cachedValidator?.etag) {
headers["If-None-Match"] = cachedValidator.etag;
}
if (cachedValidator?.lastModified) {
headers["If-Modified-Since"] = cachedValidator.lastModified;
}
try {
const response = await fetchFn(requestUrl, {
method: "GET",
cache: "no-store",
...(Object.keys(headers).length ? { headers } : {}),
});
const statusCode = Number(response?.status) || 0;
const responseEtag = getResponseHeader(response, "etag");
const responseLastModified = getResponseHeader(response, "last-modified");
if (statusCode === 304) {
const remoteVersion = normalizeVersion(cachedValidator?.remoteVersion);
if (!remoteVersion) {
throw new Error("Version nicht gefunden.");
}
nextValidators = mergeValidatorEntry(nextValidators, sourceUrl, {
remoteVersion,
etag: responseEtag || cachedValidator?.etag || "",
lastModified: responseLastModified || cachedValidator?.lastModified || "",
});
return {
remoteVersion,
sourceUrl,
validators: nextValidators,
};
}
if (!response || !response.ok) {
throw new Error(`HTTP ${statusCode}`);
}
const version = parseUserscriptVersion(await response.text());
if (version) {
nextValidators = mergeValidatorEntry(nextValidators, sourceUrl, {
remoteVersion: version,
etag: responseEtag,
lastModified: responseLastModified,
});
return {
remoteVersion: version,
sourceUrl,
validators: nextValidators,
};
}
throw new Error("Version nicht gefunden.");
} catch (error) {
lastError = error;
}
}
throw lastError || new Error("Versionsabgleich fehlgeschlagen.");
}
function readStoredUpdateStatus(options = {}) {
const windowRef = options.windowRef || window;
const installedVersion = normalizeVersion(options.installedVersion || APP_VERSION);
const storageRef = options.storageRef || getUpdateStorageRef(windowRef);
const capable = Boolean(options.fetchFn || getUpdateFetchFn(windowRef));
const storedPayload = readStoredUpdatePayload(storageRef);
if (!storedPayload || typeof storedPayload !== "object") {
return createBaseUpdateStatus(installedVersion, capable);
}
return createResolvedUpdateStatus({
capable,
installedVersion,
remoteVersion: storedPayload.remoteVersion,
checkedAt: storedPayload.checkedAt,
sourceUrl: storedPayload.sourceUrl,
validators: storedPayload.validators,
});
}
function shouldRefreshUpdateStatus(updateStatus, now = Date.now()) {
const checkedAt = Number(updateStatus?.checkedAt || 0);
if (checkedAt <= 0) {
return true;
}
return Number(now) - checkedAt >= UPDATE_CHECK_TTL_MS;
}
async function resolveLatestUpdateStatus(options = {}) {
const windowRef = options.windowRef || window;
const installedVersion = normalizeVersion(options.installedVersion || APP_VERSION);
const force = Boolean(options.force);
const now = Number(options.now || Date.now());
const fetchFn = options.fetchFn || getUpdateFetchFn(windowRef);
const storageRef = options.storageRef || getUpdateStorageRef(windowRef);
const cachedStatus = readStoredUpdateStatus({
windowRef,
installedVersion,
fetchFn,
storageRef,
});
if (!fetchFn) {
return cachedStatus;
}
if (!force && !shouldRefreshUpdateStatus(cachedStatus, now)) {
return cachedStatus;
}
try {
const remoteInfo = await fetchRemoteVersion(fetchFn, {
now,
validators: cachedStatus.validators,
});
const nextStatus = createResolvedUpdateStatus({
capable: true,
installedVersion,
remoteVersion: remoteInfo.remoteVersion,
checkedAt: now,
sourceUrl: remoteInfo.sourceUrl,
validators: remoteInfo.validators,
});
writeStoredUpdatePayload(storageRef, nextStatus);
return nextStatus;
} catch (error) {
const message = String(error?.message || "Update-Prüfung fehlgeschlagen.").trim();
if (cachedStatus.checkedAt > 0 && cachedStatus.remoteVersion) {
const staleStatus = {
...cachedStatus,
error: message,
stale: true,
checkedAt: now,
};
writeStoredUpdatePayload(storageRef, staleStatus);
return staleStatus;
}
const errorStatus = createResolvedUpdateStatus({
capable: true,
installedVersion,
remoteVersion: "",
checkedAt: now,
sourceUrl: "",
error: message,
validators: cachedStatus.validators,
});
writeStoredUpdatePayload(storageRef, errorStatus);
return errorStatus;
}
}
function isLoaderRuntimeActive(windowRef = window) {
return Boolean(windowRef?.[LOADER_GUARD_KEY]);
}
function openUserscriptInstall(windowRef = window) {
const installUrl = buildCacheBustedUrl(USERSCRIPT_DOWNLOAD_URL, Date.now()) || USERSCRIPT_DOWNLOAD_URL;
if (typeof windowRef?.open === "function") {
const openedWindow = windowRef.open(installUrl, "_blank", "noopener,noreferrer");
if (openedWindow && typeof openedWindow.focus === "function") {
openedWindow.focus();
}
return Boolean(openedWindow);
}
if (windowRef?.location) {
windowRef.location.href = installUrl;
return true;
}
return false;
}
function reloadForLoaderUpdate(windowRef = window) {
if (typeof windowRef?.location?.reload === "function") {
windowRef.location.reload();
return true;
}
return false;
}
// Presentation layer: UI rendering and interaction wiring.
function getReadmeStatusMessageDoc(message) {
const text = normalizeText(message || "");
if (!text) {
return null;
}
const exact = (value) => text === value;
const prefix = (value) => text.startsWith(value);
const statusDocs = [
{ anchor: "statusmeldung-api-auth-fehlt", match: () => exact("API Auth fehlt") || exact("Kein Autodarts-Token gefunden. Bitte einloggen und Seite neu laden.") || exact("Kein Auth-Token gefunden. Bitte neu einloggen.") || exact("Auto-Sync pausiert: kein Auth-Token gefunden. Bitte neu einloggen.") },
{ anchor: "statusmeldung-api-auth-abgelaufen", match: () => exact("API Auth abgelaufen") || exact("Auth abgelaufen.") || exact("Auth abgelaufen. Bitte neu einloggen.") || exact("Auto-Sync pausiert: Auth abgelaufen. Bitte neu einloggen.") },
{ anchor: "statusmeldung-api-auth-bereit", match: () => exact("API Auth bereit") },
{ anchor: "statusmeldung-board-aktiv", match: () => prefix("Board aktiv (") },
{ anchor: "statusmeldung-board-id-ungueltig", match: () => prefix("Board-ID ungültig (") || prefix("Board-ID ist ungültig (") },
{ anchor: "statusmeldung-kein-aktives-board", match: () => exact("Kein aktives Board") || exact("Board-ID fehlt. Bitte einmal manuell eine Lobby öffnen und Board auswählen.") },
{ anchor: "statusmeldung-auto-lobby-on", match: () => exact("Auto-Lobby ON") },
{ anchor: "statusmeldung-auto-lobby-off", match: () => exact("Auto-Lobby OFF") || exact("Auto-Lobby ist deaktiviert.") || exact("Auto-Lobby ist deaktiviert. Bitte im Tab Einstellungen aktivieren.") || exact("Auto-Lobby ist deaktiviert. Aktivieren Sie die Funktion im Tab Einstellungen.") },
{ anchor: "statusmeldung-runtime-hinweis-api-voraussetzungen", match: () => exact("Hinweis: Für API-Halbautomatik werden Auth-Token und aktives Board benötigt.") },
{ anchor: "statusmeldung-freilos-bye-kein-api-sync-erforderlich", match: () => exact("Freilos (Bye): kein API-Sync erforderlich") },
{ anchor: "statusmeldung-api-sync-abgeschlossen", match: () => exact("API-Sync: abgeschlossen") },
{ anchor: "statusmeldung-api-sync-aktiv", match: () => prefix("API-Sync: aktiv (Lobby ") },
{ anchor: "statusmeldung-api-sync-fehler", match: () => prefix("API-Sync: Fehler") || prefix("Auto-Sync Fehler bei ") || prefix("Matchstart fehlgeschlagen: ") || prefix("Letzter Sync-Fehler: ") || exact("Match ist im Fehlerstatus.") || exact("Gewinner konnte nicht eindeutig zugeordnet werden.") || exact("Auto-Sync konnte Ergebnis nicht speichern.") },
{ anchor: "statusmeldung-api-sync-nicht-gestartet", match: () => exact("API-Sync: nicht gestartet") },
{ anchor: "statusmeldung-match-nicht-verfuegbar", match: () => exact("Match nicht verfügbar.") },
{ anchor: "statusmeldung-match-bereits-abgeschlossen", match: () => exact("Match ist bereits abgeschlossen.") },
{ anchor: "statusmeldung-paarung-steht-noch-nicht-fest", match: () => exact("Paarung steht noch nicht fest.") },
{ anchor: "statusmeldung-vorgaenger-match-muss-zuerst-abgeschlossen-werden", match: () => prefix("Vorgänger-Match ") && text.endsWith(" muss zuerst abgeschlossen werden.") },
{ anchor: "statusmeldung-ergebnis-bereits-im-turnier-gespeichert", match: () => exact("Ergebnis bereits im Turnier gespeichert.") || exact("Ergebnis war bereits übernommen.") },
{ anchor: "statusmeldung-kein-eindeutiger-statistik-host", match: () => exact("Kein eindeutiger Statistik-Host für diese Lobby auf der History-Seite gefunden.") },
{ anchor: "statusmeldung-statistik-host-konnte-nicht-zugeordnet-werden", match: () => exact("Statistik-Host konnte nicht auf einen Kartenbereich zugeordnet werden.") },
{ anchor: "statusmeldung-mehrdeutiger-statistik-host", match: () => exact("Mehrdeutiger Statistik-Host: Mehrere passende Bereiche auf der Seite gefunden.") || exact("Statistik-Bereich ist nicht eindeutig. Import ist gesperrt.") },
{ anchor: "statusmeldung-keine-eindeutige-statistik-tabelle", match: () => exact("Im erkannten Statistik-Bereich wurde keine eindeutige Tabelle gefunden.") },
{ anchor: "statusmeldung-mehrere-statistik-tabellen", match: () => exact("Im Statistik-Bereich wurden mehrere Tabellen gefunden. Import wurde aus Sicherheitsgründen gestoppt.") },
{ anchor: "statusmeldung-leg-abweichung-bestaetigung-erforderlich", match: () => prefix("Leg-Abweichung erkannt: Statistik ") || prefix("Leg-Abweichung erkannt. Bitte Übernahme ") || exact("Explizite Bestätigung erforderlich.") },
{ anchor: "statusmeldung-bestaetigung-abgelaufen", match: () => exact("Bestätigung ist abgelaufen. Bitte den Import erneut starten.") },
{ anchor: "statusmeldung-bestaetigung-ungueltig", match: () => exact("Bestätigung ist ungültig. Bitte den Import erneut starten.") || exact("Bestätigung passt nicht mehr zur aktuellen Statistik. Bitte erneut bestätigen.") },
{ anchor: "statusmeldung-statistik-api-fallback", match: () => exact("Statistik konnte nicht vollständig gelesen werden. Beim Klick wird API-Fallback genutzt.") },
{ anchor: "statusmeldung-import-bereit-sieger-laut-statistik", match: () => prefix("Import bereit. Sieger laut Statistik: ") },
{ anchor: "statusmeldung-match-verknuepft-ergebnis-kann-jetzt-gespeichert-werden", match: () => exact("Match verknüpft. Ergebnis kann jetzt übernommen werden.") },
{ anchor: "statusmeldung-kein-direkt-verknuepftes-match-gefunden", match: () => exact("Kein direkt verknüpftes Match gefunden. Ergebnisübernahme versucht Zuordnung über die Statistik.") },
{ anchor: "statusmeldung-keine-lobby-id-erkannt", match: () => exact("Keine Lobby-ID erkannt.") || exact("Keine Lobby-ID vorhanden.") },
{ anchor: "statusmeldung-mehrdeutige-zuordnung-lobby", match: () => exact("Mehrdeutige Zuordnung: mehrere offene Turnier-Matches passen zur Lobby. Bitte in der Ergebnisführung manuell speichern.") },
{ anchor: "statusmeldung-kein-offenes-turnier-match-fuer-diese-lobby-gefunden", match: () => exact("Kein offenes Turnier-Match für diese Lobby gefunden.") },
{ anchor: "statusmeldung-kein-offenes-turnier-match-aus-lobby-id-oder-statistik-spielern-gefunden", match: () => exact("Kein offenes Turnier-Match aus Lobby-ID oder Statistik-Spielern gefunden.") },
{ anchor: "statusmeldung-api-ergebnis-noch-nicht-final-verfuegbar", match: () => exact("API-Ergebnis ist noch nicht final verfügbar.") || exact("Match-Stats noch nicht verfügbar.") || exact("Noch kein finales Ergebnis verfügbar. Match läuft ggf. noch.") },
{ anchor: "statusmeldung-ergebnis-importiert", match: () => exact("Ergebnis übernommen.") || exact("Ergebnis wurde aus der Match-Statistik übernommen.") || prefix("Ergebnis übernommen. Legs wurden nach bestätigter Abweichung auf First to ") || exact("Ergebnis wurde in xLokales Turnier übernommen.") },
{ anchor: "statusmeldung-mehrdeutige-zuordnung-statistik-spieler", match: () => exact("Mehrdeutige Zuordnung: mehrere offene Turnier-Matches passen zu diesen Spielern.") },
{ anchor: "statusmeldung-sieger-konnte-aus-der-statistik-nicht-eindeutig-bestimmt-werden", match: () => exact("Sieger konnte aus der Statistik nicht eindeutig bestimmt werden.") },
{ anchor: "statusmeldung-ergebnis-konnte-nicht-aus-der-statistik-gespeichert-werden", match: () => exact("Ergebnis konnte nicht aus der Statistik gespeichert werden.") },
];
const match = statusDocs.find((entry) => entry.match());
if (!match) {
return null;
}
return {
href: `${README_BASE_URL}#${match.anchor}`,
title: `README: ${text}`,
};
}
function renderDocLinkableMessage(message, options = {}) {
const text = normalizeText(message || "");
if (!text) {
return "";
}
const tagName = normalizeToken(options.tagName || "span") || "span";
const className = normalizeText(options.className || "");
const attributes = normalizeText(options.attributes || "");
const fallbackTitle = normalizeText(options.title || "");
const doc = getReadmeStatusMessageDoc(text);
const attributeHtml = attributes ? ` ${attributes}` : "";
const classHtml = className ? ` class="${escapeHtml(className)}"` : "";
const titleText = doc ? (doc.title || fallbackTitle) : fallbackTitle;
const titleHtml = titleText ? ` title="${escapeHtml(titleText)}"` : "";
if (!doc) {
return `<${tagName}${classHtml}${titleHtml}${attributeHtml}>${escapeHtml(text)}${tagName}>`;
}
const linkClassName = normalizeText(`${className} ata-doc-linkable`);
return `${escapeHtml(text)} `;
}
function renderInfoLinks(links) {
if (!Array.isArray(links) || !links.length) {
return "";
}
const linksHtml = links
.map((entry) => {
const href = String(entry?.href || "").trim();
if (!href) {
return "";
}
const kind = normalizeToken(entry?.kind || "tech");
const isRuleLink = kind === "rule" || kind === "rules" || kind === "regel";
const symbol = isRuleLink ? "§" : "ⓘ";
const className = isRuleLink
? "ata-help-link ata-help-link-rule"
: "ata-help-link ata-help-link-tech";
const label = normalizeText(entry?.label) || "Mehr Informationen";
const title = normalizeText(entry?.title) || label;
return `
${symbol}
`;
})
.filter(Boolean)
.join("");
if (!linksHtml) {
return "";
}
return `${linksHtml} `;
}
function renderSectionHeading(title, links = []) {
return `
${escapeHtml(title)}
${renderInfoLinks(links)}
`;
}
function formatDurationMinutes(value) {
const totalMinutes = Math.max(0, Math.round(Number(value) || 0));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours <= 0) {
return `${minutes}m`;
}
if (minutes === 0) {
return `${hours}h`;
}
return `${hours}h ${minutes}m`;
}
function formatDurationDecimal(value, digits = 1) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return `0,${"0".repeat(Math.max(0, digits))}`;
}
return numeric.toFixed(digits).replace(".", ",");
}
function renderTournamentDurationEstimate(estimate, options = {}) {
const visible = options?.visible !== false;
const helpLinks = renderInfoLinks([
{ href: README_TOURNAMENT_CREATE_URL, kind: "tech", label: "Erkl\u00e4rung zur Turnierzeit-Prognose \u00f6ffnen", title: "README: Turnier anlegen" },
{ href: README_SETTINGS_URL, kind: "tech", label: "Einstellungen f\u00fcr das Zeitprofil \u00f6ffnen", title: "README: Einstellungen" },
]);
const visibilityButtonLabel = visible ? "Ausblenden" : "Einblenden";
const visibilityToggleButton = `${visibilityButtonLabel} `;
const estimateReason = normalizeText(estimate?.reason || "");
const boardCount = sanitizeTournamentBoardCount(
estimate?.boardCount,
TOURNAMENT_DURATION_DEFAULT_BOARD_COUNT,
);
const boardLabel = boardCount === 1 ? "Board" : "Boards";
if (!estimate?.ready) {
return `
Voraussichtliche Turnierzeit
${visibilityToggleButton}
${helpLinks}
${!visible ? `Prognose ist ausgeblendet.
` : `
Noch nicht berechenbar
${escapeHtml(estimateReason || "Die Sch\u00e4tzung startet, sobald die Konfiguration f\u00fcr den gew\u00e4hlten Modus g\u00fcltig ist.")}
Annahme: Planung mit ${escapeHtml(String(boardCount))} ${escapeHtml(boardLabel)} und abh\u00e4ngigkeitsbasierter Parallelisierung.
`}
`;
}
if (!visible) {
return `
Voraussichtliche Turnierzeit
${visibilityToggleButton}
${helpLinks}
ca. ${escapeHtml(formatDurationMinutes(estimate.likelyMinutes))}
Prognose und Parameter sind ausgeblendet.
`;
}
const averageParallelMatches = Math.max(1, Number(estimate.averageParallelMatches || 1));
const peakParallelMatches = clampInt(estimate.peakParallelMatches, 1, 1, boardCount);
const boardUtilization = Math.max(0, Math.min(1, Number(estimate.boardUtilization || 0)));
const utilizationPercent = Math.round(boardUtilization * 100);
const setupSummary = `${estimate.x01.baseScore}, ${estimate.x01.inMode} In, ${estimate.x01.outMode} Out, Bull-off ${estimate.x01.bullOffMode}, Best of ${estimate.bestOfLegs}`;
return `
Voraussichtliche Turnierzeit
${visibilityToggleButton}
${helpLinks}
ca. ${escapeHtml(formatDurationMinutes(estimate.likelyMinutes))}
${escapeHtml(String(estimate.participantCount))} Teilnehmer
${escapeHtml(String(estimate.matchCount))} Spiele
${escapeHtml(String(boardCount))} ${escapeHtml(boardLabel)}
${escapeHtml(String(estimate.scheduleWaves))} Match-Wellen
\u00d8 ${escapeHtml(formatDurationDecimal(averageParallelMatches, 2))} Spiele parallel
Peak ${escapeHtml(String(peakParallelMatches))}/${escapeHtml(String(boardCount))} Boards
Auslastung ${escapeHtml(String(utilizationPercent))}%
Durchschnitt ${escapeHtml(formatDurationDecimal(estimate.matchMinutes))} min/Spiel
Profil ${escapeHtml(estimate.profile.label)}
Realistisch: ${escapeHtml(formatDurationMinutes(estimate.lowMinutes))} - ${escapeHtml(formatDurationMinutes(estimate.highMinutes))}
${escapeHtml(estimate.profile.description)}
Parallelisierung ber\u00fccksichtigt Match-Abh\u00e4ngigkeiten und blockierte Spieler-Slots.
Basis: ${escapeHtml(setupSummary)}.
`;
}
function renderTournamentDurationProgress(progress, options = {}) {
const visible = options?.visible !== false;
if (!progress?.ready || !visible) {
return "";
}
const completed = clampInt(progress.completedMatches, 0, 0, 9999);
if (completed <= 0) {
return `
Laufende Restzeit-Prognose
Startet automatisch nach dem ersten gespeicherten Ergebnis.
`;
}
const remaining = clampInt(progress.remainingMatches, 0, 0, 9999);
const progressPercent = Math.round(Math.max(0, Math.min(1, Number(progress.progressRatio || 0))) * 100);
return `
Laufende Restzeit-Prognose
Rest ca. ${escapeHtml(formatDurationMinutes(progress.remainingLikelyMinutes))}
Fortschritt ${escapeHtml(String(completed))}/${escapeHtml(String(completed + remaining))} (${escapeHtml(String(progressPercent))}%)
Offene Match-Wellen ${escapeHtml(String(progress.remainingScheduleWaves))}
Rest realistisch: ${escapeHtml(formatDurationMinutes(progress.remainingLowMinutes))} - ${escapeHtml(formatDurationMinutes(progress.remainingHighMinutes))}
Die Restzeit wird aus offenem Matchplan und gespeichertem Turnierfortschritt statisch neu berechnet.
`;
}
function buildStyles() {
return ATA_UI_MAIN_CSS;
}
function buildShellHtml() {
const tabs = TAB_META.map((tab) => `
${escapeHtml(tab.label)}
`).join("");
const noticeHtml = state.notice.message
? renderDocLinkableMessage(state.notice.message, {
tagName: "div",
className: `ata-notice ata-notice-${state.notice.type}`,
})
: "";
const runtimeStatusHtml = renderRuntimeStatusBar();
return `
${tabs}
${runtimeStatusHtml}
${noticeHtml}${renderActiveTab()}
`;
}
function renderActiveTab() {
switch (state.activeTab) {
case "matches":
return renderMatchesTab();
case "view":
return renderViewTab();
case "io":
return renderIOTab();
case "settings":
return renderSettingsTab();
case "tournament":
default:
return renderTournamentTab();
}
}
function renderTournamentTab() {
const tournament = state.store.tournament;
const durationEstimateVisible = state.store?.ui?.durationEstimateVisible !== false;
const tournamentTimeProfile = sanitizeTournamentTimeProfile(
state.store?.settings?.tournamentTimeProfile,
TOURNAMENT_TIME_PROFILE_NORMAL,
);
const tournamentTimeProfileOptions = TOURNAMENT_TIME_PROFILES.map((profileId) => {
const profileMeta = getTournamentTimeProfileMeta(profileId);
const selectedAttr = tournamentTimeProfile === profileId ? "selected" : "";
const label = profileId === TOURNAMENT_TIME_PROFILE_NORMAL
? `${profileMeta.label} (empfohlen)`
: profileMeta.label;
return `${escapeHtml(label)} `;
}).join("");
if (!tournament) {
const draft = normalizeCreateDraft(state.store?.ui?.createDraft, state.store?.settings);
const randomizeChecked = draft.randomizeKoRound1 ? "checked" : "";
const thirdPlaceChecked = draft.enableThirdPlaceMatch ? "checked" : "";
const modeLimitSummary = buildModeParticipantLimitSummary();
const startScoreOptions = X01_START_SCORE_OPTIONS.map((score) => (
`${score} `
)).join("");
const durationEstimate = estimateTournamentDurationFromDraft(draft, state.store.settings);
const activePresetId = getAppliedCreatePresetId(draft);
const presetStatusLabel = `Preset aktiv: ${getCreatePresetLabel(activePresetId)}`;
const presetOptions = [
...getCreatePresetCatalog().map((preset) => (
`${escapeHtml(preset.label)} `
)),
`Individuell / Manuell `,
].join("");
const bullModeDisabled = draft.x01BullOffMode === "Off";
const bullModeDisabledAttr = bullModeDisabled ? "disabled" : "";
const bullModeHiddenInput = bullModeDisabled
? ` `
: "";
const createHeadingLinks = [
{ href: README_TOURNAMENT_CREATE_URL, kind: "tech", label: "Erklärung zur Turniererstellung öffnen", title: "README: Turnier anlegen" },
{ href: README_INFO_SYMBOLS_URL, kind: "tech", label: "Legende der Info-Symbole öffnen", title: "README: Info-Symbole" },
];
const modeHelpLinks = renderInfoLinks([
{ href: README_TOURNAMENT_MODES_URL, kind: "tech", label: "Erklärung der Modi öffnen", title: "README: Turniermodi" },
{ href: DRA_GUI_RULE_MODE_FORMATS_URL, kind: "rule", label: "DRA-Regelerklärung zu Modus und Format öffnen", title: "DRA-Regeln in der GUI: Modus und Format" },
]);
const drawHelpLinks = renderInfoLinks([
{ href: README_TOURNAMENT_MODES_URL, kind: "tech", label: "Open Draw und gesetzter Draw erklärt", title: "README: KO-Modus" },
{ href: DRA_GUI_RULE_OPEN_DRAW_URL, kind: "rule", label: "DRA-Regelerklärung zu Open Draw öffnen", title: "DRA-Regeln in der GUI: Open Draw" },
]);
const modeLimitHelpLinks = renderInfoLinks([
{ href: DRA_GUI_RULE_PARTICIPANT_LIMITS_URL, kind: "rule", label: "DRA-Regelerklärung zu Limits öffnen", title: "DRA-Regeln in der GUI: Teilnehmerlimits" },
]);
return `
${renderSectionHeading("Neues Turnier erstellen", createHeadingLinks)}
`;
}
const modeLabel = tournament.mode === "ko"
? "KO (Straight Knockout)"
: tournament.mode === "league"
? "Liga (Round Robin)"
: "Gruppenphase + KO (Round Robin + Straight Knockout)";
const participantsHtml = tournament.participants.map((participant) => (
`${escapeHtml(participant.name)} `
)).join("");
const participantsCount = tournament.participants.length;
const x01Settings = normalizeTournamentX01Settings(tournament?.x01, tournament?.startScore);
const activePresetId = getAppliedCreatePresetId(tournament);
const x01PresetLabel = getCreatePresetLabel(activePresetId);
const x01BullModeLabel = x01Settings.bullOffMode === "Off"
? "Bull-Modus deaktiviert"
: `Bull-Modus ${x01Settings.bullMode}`;
const legsToWin = getLegsToWin(tournament.bestOfLegs);
const drawMode = normalizeKoDrawMode(tournament?.ko?.drawMode, KO_DRAW_MODE_SEEDED);
const drawModeLabel = drawMode === KO_DRAW_MODE_OPEN_DRAW ? "Open Draw" : "Gesetzter Draw";
const drawLockLabel = tournament?.ko?.drawLocked !== false ? "Draw-Lock aktiv" : "Draw-Lock aus";
const thirdPlaceLabel = tournament?.ko?.enableThirdPlaceMatch === true
? "Spiel um Platz 3: aktiv"
: "Spiel um Platz 3: aus";
const primaryTags = [
{ text: `Best of ${tournament.bestOfLegs} Legs`, cls: "ata-info-tag ata-info-tag-key" },
{ text: `First to ${legsToWin} Legs`, cls: "ata-info-tag" },
{ text: `Startpunkte ${tournament.startScore}`, cls: "ata-info-tag" },
...(tournament.mode === "ko"
? [
{ text: drawModeLabel, cls: "ata-info-tag ata-info-tag-accent" },
{ text: drawLockLabel, cls: "ata-info-tag" },
{ text: thirdPlaceLabel, cls: "ata-info-tag" },
]
: []),
];
const x01Tags = [
{ text: `Preset ${x01PresetLabel}`, cls: "ata-info-tag ata-info-tag-key" },
{ text: `${x01Settings.inMode} In`, cls: "ata-info-tag" },
{ text: `${x01Settings.outMode} Out`, cls: "ata-info-tag" },
{ text: `Bull-off ${x01Settings.bullOffMode}`, cls: "ata-info-tag" },
{ text: x01BullModeLabel, cls: "ata-info-tag" },
{ text: `Max. Runden ${x01Settings.maxRounds}`, cls: "ata-info-tag" },
];
const primaryTagsHtml = primaryTags.map((tag) => `${escapeHtml(tag.text)} `).join("");
const x01TagsHtml = x01Tags.map((tag) => `${escapeHtml(tag.text)} `).join("");
const activeTournamentHeadingLinks = [
{ href: README_TOURNAMENT_MODES_URL, kind: "tech", label: "Turniermodus-Erklärung öffnen", title: "README: Turniermodi" },
];
const activeFormatHelpLinks = renderInfoLinks([
{ href: DRA_GUI_RULE_MODE_FORMATS_URL, kind: "rule", label: "DRA-Regelerklärung zu Modus und Format öffnen", title: "DRA-Regeln in der GUI: Modus und Format" },
]);
const durationEstimate = estimateTournamentDurationFromTournament(tournament, state.store.settings);
const durationProgress = estimateTournamentDurationProgressFromTournament(tournament, state.store.settings);
const activeBoardCount = sanitizeTournamentBoardCount(
tournament?.duration?.boardCount,
TOURNAMENT_DURATION_DEFAULT_BOARD_COUNT,
);
return `
${renderSectionHeading("Aktives Turnier", activeTournamentHeadingLinks)}
${escapeHtml(tournament.name)}
${escapeHtml(modeLabel)}
${renderSectionHeading("Turnierzeit-Prognose", [
{ href: README_TOURNAMENT_CREATE_URL, kind: "tech", label: "Erklärung zur Turnierzeit-Prognose öffnen", title: "README: Turnier anlegen" },
{ href: README_SETTINGS_URL, kind: "tech", label: "Einstellungen-Dokumentation öffnen", title: "README: Einstellungen" },
])}
Die Restzeit-Prognose wird bei gespeicherten Ergebnissen statisch neu berechnet.
${renderTournamentDurationEstimate(durationEstimate, { visible: durationEstimateVisible })}
${renderTournamentDurationProgress(durationProgress, { visible: durationEstimateVisible })}
Turnier zur\u00fccksetzen
Dieser Schritt l\u00f6scht alle Spielst\u00e4nde. Bitte vorher exportieren.
Turnier l\u00f6schen
`;
}
function renderMatchesTab() {
const tournament = state.store.tournament;
if (!tournament) {
return `Keine Turnierdaten Bitte zuerst ein Turnier erstellen.
`;
}
const activeStartedMatch = findActiveStartedMatch(tournament);
const sortMode = sanitizeMatchesSortMode(state.store?.ui?.matchesSortMode, MATCH_SORT_MODE_READY_FIRST);
const sortOptions = [
{ id: MATCH_SORT_MODE_READY_FIRST, label: "Spielbar zuerst" },
{ id: MATCH_SORT_MODE_ROUND, label: "Phase/Spiel" },
{ id: MATCH_SORT_MODE_STATUS, label: "Status" },
];
const matches = sortMatchesForDisplay(tournament, sortMode);
const legsToWin = getLegsToWin(tournament.bestOfLegs);
const suggestedNextMatch = findSuggestedNextMatch(tournament);
const suggestedNextMatchId = suggestedNextMatch?.id || "";
const koFinalRound = getMatchesByStage(tournament, MATCH_STAGE_KO).reduce((maxRound, koMatch) => {
if (normalizeText(koMatch?.meta?.bracket?.matchRole || "") === "third_place") {
return maxRound;
}
const roundNumber = Number.parseInt(String(koMatch?.round || "0"), 10);
return Number.isFinite(roundNumber) && roundNumber > maxRound ? roundNumber : maxRound;
}, 0);
const cards = matches.map((match) => {
const player1 = participantNameById(tournament, match.player1Id);
const player2 = participantNameById(tournament, match.player2Id);
const winner = participantNameById(tournament, match.winnerId);
const isOpenSlot = (name) => name === "\u2205 offen";
const playability = getMatchEditability(tournament, match);
const editable = playability.editable;
const auto = ensureMatchAutoMeta(match);
const isCompleted = match.status === STATUS_COMPLETED;
const isByeCompletion = isCompleted && isByeMatchResult(match);
const isAutoStarted = match.status === STATUS_PENDING && auto.status === "started" && Boolean(auto.lobbyId);
const isBlockedPending = match.status === STATUS_PENDING && !editable;
const isReadyPending = match.status === STATUS_PENDING && editable;
const isSuggestedNext = Boolean(suggestedNextMatchId) && match.id === suggestedNextMatchId;
const isThirdPlaceMatch = normalizeText(match?.meta?.bracket?.matchRole || "") === "third_place";
const isKoFinal = match.stage === MATCH_STAGE_KO
&& !isThirdPlaceMatch
&& koFinalRound > 0
&& Number(match.round) === koFinalRound;
const koRoundLabel = match.stage === MATCH_STAGE_KO && !isThirdPlaceMatch
? getKoRoundLabel(match.round, koFinalRound || match.round)
: "";
const stageLabel = match.stage === MATCH_STAGE_GROUP
? `Gruppe ${match.groupId || "?"}`
: match.stage === MATCH_STAGE_LEAGUE
? "Liga (Round Robin)"
: (isThirdPlaceMatch ? "KO (Spiel um Platz 3)" : "KO (Straight Knockout)");
const startUi = getApiMatchStartUi(tournament, match, activeStartedMatch);
const startDisabledAttr = startUi.disabled ? "disabled" : "";
const startTitleAttr = startUi.title ? `title="${escapeHtml(startUi.title)}"` : "";
const autoStatus = getApiMatchStatusText(match);
let statusLine = "";
if (match.status === STATUS_PENDING) {
if (!editable && playability.reason) {
statusLine = auto.status === "idle"
? playability.reason
: `${playability.reason} - ${autoStatus}`;
} else if (auto.status !== "idle") {
statusLine = autoStatus;
}
} else if (!isByeCompletion && auto.status !== "completed") {
statusLine = autoStatus;
}
const matchCellText = isThirdPlaceMatch
? "Spiel um Platz 3"
: match.stage === MATCH_STAGE_KO
? getKoRoundMatchLabel(match.round, koFinalRound || match.round, match.number)
: `Runde ${match.round} / Spiel ${match.number}`;
const matchCellHelpText = isThirdPlaceMatch
? "Spiel um Platz 3 = separates Bronze-Match, getrennt vom Champion-Pfad."
: match.stage === MATCH_STAGE_KO
? `KO-Phase = ${koRoundLabel} bzw. bei großen Feldern Letzte N, Spiel = Paarung innerhalb dieser Phase.`
: "Runde = Turnierrunde, Spiel = Paarung innerhalb dieser Runde.";
const legsP1HelpText = `Hier die Anzahl gewonnener Legs von ${player1} eintragen (nicht Punkte pro Wurf). Ziel: ${legsToWin} Legs f\u00fcr den Matchgewinn.`;
const legsP2HelpText = `Hier die Anzahl gewonnener Legs von ${player2} eintragen (nicht Punkte pro Wurf). Ziel: ${legsToWin} Legs f\u00fcr den Matchgewinn.`;
const saveHelpText = `Speichert Legs f\u00fcr ${player1} vs ${player2}. Sieger wird automatisch aus den Legs bestimmt. Sieger muss ${legsToWin} Legs erreichen.`;
const rowClasses = [
"ata-match-card",
isCompleted ? "ata-row-completed" : "",
isByeCompletion ? "ata-row-bye" : "",
isAutoStarted ? "ata-row-live" : "",
isReadyPending ? "ata-row-ready" : "",
isSuggestedNext ? "ata-row-next" : "",
isKoFinal ? "ata-row-final" : "",
isBlockedPending ? "ata-row-blocked" : "",
!editable ? "ata-row-inactive" : "",
].filter(Boolean).join(" ");
const statusBadgeText = isByeCompletion ? "Freilos (Bye)" : (isCompleted ? "Abgeschlossen" : "Offen");
const contextPillClass = isByeCompletion
? "ata-match-context-pill ata-match-context-bye"
: (isCompleted ? "ata-match-context-pill ata-match-context-completed" : "ata-match-context-pill ata-match-context-open");
const contextText = `${stageLabel}, ${matchCellText}, ${statusBadgeText}`;
const summaryText = isCompleted
? (isByeCompletion
? `Weiter (Bye): ${winner}`
: (isKoFinal
? `Champion: ${winner} (${match.legs.p1}:${match.legs.p2})`
: (isThirdPlaceMatch
? `Platz 3: ${winner} (${match.legs.p1}:${match.legs.p2})`
: `Sieger: ${winner} (${match.legs.p1}:${match.legs.p2})`)))
: "";
const advanceClasses = [
"ata-match-advance-pill",
isByeCompletion ? "ata-match-advance-bye" : "",
isKoFinal ? "ata-match-advance-final" : "",
].filter(Boolean).join(" ");
const buildPairingPlayerHtml = (name, participantId) => {
const classes = ["ata-pairing-player"];
if (isOpenSlot(name)) {
classes.push("ata-open-slot");
return `${escapeHtml(name)} `;
}
if (isCompleted && match.winnerId) {
if (participantId === match.winnerId) {
classes.push("is-winner");
if (isKoFinal) {
classes.push("is-champion");
}
} else if (participantId === match.player1Id || participantId === match.player2Id) {
classes.push("is-loser");
}
}
return `${escapeHtml(name)} `;
};
const player1PairingHtml = buildPairingPlayerHtml(player1, match.player1Id);
const player2PairingHtml = buildPairingPlayerHtml(player2, match.player2Id);
const editorHtml = editable
? `
`
: "";
const summaryHtml = summaryText
? `${escapeHtml(summaryText)} `
: "";
const nextPillHtml = isSuggestedNext
? `N\u00e4chstes Match `
: "";
const finalPillHtml = isKoFinal
? `🏆 Finale `
: "";
const statusLineHtml = statusLine
? renderDocLinkableMessage(statusLine, {
tagName: "div",
className: "ata-match-note",
})
: "";
return `
${player1PairingHtml} vs ${player2PairingHtml}
${finalPillHtml}
${nextPillHtml}
${escapeHtml(contextText)}
${summaryHtml}
${editorHtml}
${statusLineHtml}
`;
}).join("");
const cardsHtml = cards || `Keine Matches vorhanden.
`;
const resultHeadingLinks = [
{ href: README_API_AUTOMATION_URL, kind: "tech", label: "Erklärung zur API-Halbautomatik öffnen", title: "README: API-Halbautomatik" },
{ href: DRA_GUI_RULE_TIE_BREAK_URL, kind: "rule", label: "DRA-Regelerklärung zum Tie-Break öffnen", title: "DRA-Regeln in der GUI: Tie-Break" },
];
const nextMatchHelpLinks = renderInfoLinks([
{ href: README_API_AUTOMATION_URL, kind: "tech", label: "Ablauf der Ergebnisführung öffnen", title: "README: API-Halbautomatik und Ergebnisführung" },
{ href: README_TOURNAMENT_MODES_URL, kind: "tech", label: "Turniermodus-Kontext öffnen", title: "README: Turniermodi" },
]);
const nextHintHtml = suggestedNextMatchId
? `Hinweis: Die Markierung "Nächstes Match" zeigt die empfohlene nächste Paarung (PDC: Next Match) ${nextMatchHelpLinks}.
`
: "";
const sortButtonsHtml = sortOptions.map((option) => `
${escapeHtml(option.label)}
`).join("");
return `
${renderSectionHeading("Ergebnisführung", resultHeadingLinks)}
API-Halbautomatik: Match per Klick starten, Ergebnis wird automatisch synchronisiert. Manuelle Eingabe bleibt als Fallback aktiv. ${renderInfoLinks([
{ href: README_API_AUTOMATION_URL, kind: "tech", label: "Voraussetzungen und Ablauf öffnen", title: "README: API-Halbautomatik" },
])}
${nextHintHtml}
${cardsHtml}
`;
}
function renderStandingsTable(rows, headline, headingLinks = []) {
const bodyRows = rows.map((row) => `
${row.rank}
${escapeHtml(row.name)}
${row.played}
${row.wins}
${row.draws || 0}
${row.losses}
${row.points}
${row.legDiff}
${row.legsFor}
${row.tiebreakState === "playoff_required" ? "Playoff" : "-"}
`).join("");
return `
${renderSectionHeading(headline, headingLinks)}
#
Name
Sp
S
U
N
Pkt
LegDiff
Legs+
TB
${bodyRows}
`;
}
function renderLeagueSchedule(tournament) {
const matches = getMatchesByStage(tournament, MATCH_STAGE_LEAGUE);
if (!matches.length) {
return "";
}
const rows = matches.map((match) => `
R${match.round}
${escapeHtml(participantNameById(tournament, match.player1Id))}
${escapeHtml(participantNameById(tournament, match.player2Id))}
${match.status === STATUS_COMPLETED ? escapeHtml(participantNameById(tournament, match.winnerId)) : "-"}
${match.status === STATUS_COMPLETED ? `${match.legs.p1}:${match.legs.p2}` : "-"}
`).join("");
return `
Liga-Spielplan
Runde
Spieler 1
Spieler 2
Winner
Legs
${rows}
`;
}
function renderStaticBracketFallbackMatches(tournament, matches) {
return matches
.sort((a, b) => a.number - b.number)
.map((match) => {
const isCompleted = isCompletedMatchResultValid(tournament, match);
const isBye = isCompleted && isByeMatchResult(match);
const player1Name = participantNameById(tournament, match.player1Id);
const player2Name = participantNameById(tournament, match.player2Id);
const hasLegScores = isCompleted && Number.isFinite(match?.legs?.p1) && Number.isFinite(match?.legs?.p2);
const winnerId = isCompleted ? normalizeText(match.winnerId) : "";
const player1IsWinner = Boolean(winnerId) && normalizeText(match.player1Id) === winnerId;
const player2IsWinner = Boolean(winnerId) && normalizeText(match.player2Id) === winnerId;
const player1Classes = [
"ata-bracket-player",
player1Name === "\u2205 offen" ? "ata-open-slot" : "",
player1IsWinner ? "is-winner" : "",
(isCompleted && !isBye && !player1IsWinner && normalizeText(match.player1Id)) ? "is-loser" : "",
].filter(Boolean).join(" ");
const player2Classes = [
"ata-bracket-player",
player2Name === "\u2205 offen" ? "ata-open-slot" : "",
player2IsWinner ? "is-winner" : "",
(isCompleted && !isBye && !player2IsWinner && normalizeText(match.player2Id)) ? "is-loser" : "",
].filter(Boolean).join(" ");
const statusBadgeClass = isBye
? "ata-match-status ata-match-status-bye"
: (isCompleted ? "ata-match-status ata-match-status-completed" : "ata-match-status ata-match-status-open");
const statusBadgeText = isBye ? "Freilos (Bye)" : (isCompleted ? "Abgeschlossen" : "Offen");
const statusText = !isCompleted
? "Noch nicht abgeschlossen."
: isBye
? `Freilos (Bye): ${escapeHtml(participantNameById(tournament, match.winnerId))}`
: `Gewinner: ${escapeHtml(participantNameById(tournament, match.winnerId))} (${match.legs.p1}:${match.legs.p2})`;
return `
${escapeHtml(player1Name)}
${hasLegScores ? `${match.legs.p1} ` : ""}
${escapeHtml(player2Name)}
${hasLegScores ? `${match.legs.p2} ` : ""}
${statusBadgeText}
${statusText}
`;
}).join("");
}
function renderStaticBracketFallback(tournament) {
const koMatches = getMatchesByStage(tournament, MATCH_STAGE_KO);
if (!koMatches.length) {
return `Kein KO-Turnierbaum vorhanden.
`;
}
const mainMatches = koMatches.filter((match) => normalizeText(match?.meta?.bracket?.matchRole || "") !== "third_place");
const thirdPlaceMatches = koMatches.filter((match) => normalizeText(match?.meta?.bracket?.matchRole || "") === "third_place");
const maxMainRound = mainMatches.reduce((maxRound, match) => Math.max(maxRound, clampInt(match?.round, 0, 0, 64)), 0);
const rounds = new Map();
mainMatches.forEach((match) => {
if (!rounds.has(match.round)) {
rounds.set(match.round, []);
}
rounds.get(match.round).push(match);
});
const mainRoundHtml = [...rounds.entries()]
.sort((left, right) => left[0] - right[0])
.map(([roundNumber, matches]) => `
${escapeHtml(getKoRoundLabel(roundNumber, maxMainRound || roundNumber))}
${renderStaticBracketFallbackMatches(tournament, matches)}
`).join("");
const thirdPlaceHtml = thirdPlaceMatches.length
? `
Spiel um Platz 3
${renderStaticBracketFallbackMatches(tournament, thirdPlaceMatches)}
`
: "";
return `${mainRoundHtml}${thirdPlaceHtml}
`;
}
function renderViewTab() {
const tournament = state.store.tournament;
if (!tournament) {
return `Keine Turnierdaten Bitte zuerst ein Turnier erstellen.
`;
}
let html = "";
const fallbackVisible = state.bracket.failed ? "1" : "0";
if (tournament.mode === "league") {
const standings = standingsForMatches(tournament, getMatchesByStage(tournament, MATCH_STAGE_LEAGUE));
html += renderStandingsTable(standings, "Liga-Tabelle", [
{ href: DRA_GUI_RULE_TIE_BREAK_URL, kind: "rule", label: "DRA-Regelerklärung zum Tie-Break öffnen", title: "DRA-Regeln in der GUI: Tie-Break" },
]);
html += renderLeagueSchedule(tournament);
} else if (tournament.mode === "groups_ko") {
const standingsMap = groupStandingsMap(tournament);
const groupCards = [];
const blockedGroups = [];
standingsMap.forEach((entry) => {
groupCards.push(renderStandingsTable(entry.rows, `Tabelle ${entry.group.name}`, [
{ href: DRA_GUI_RULE_TIE_BREAK_URL, kind: "rule", label: "DRA-Regelerklärung zum Tie-Break öffnen", title: "DRA-Regeln in der GUI: Tie-Break" },
]));
if (entry.groupResolution?.status === "playoff_required") {
blockedGroups.push(`${entry.group.name}: ${entry.groupResolution.reason}`);
}
});
html += `${groupCards.join("")}
`;
if (blockedGroups.length) {
html += `
${renderSectionHeading("Gruppenentscheidung offen", [
{ href: DRA_GUI_RULE_TIE_BREAK_URL, kind: "rule", label: "DRA-Regelerklärung zum Tie-Break öffnen", title: "DRA-Regeln in der GUI: Tie-Break" },
])}
KO-Qualifikation ist blockiert, bis folgende DRA-Entscheidungen geklärt sind:
${blockedGroups.map((text) => `${escapeHtml(text)} `).join("")}
`;
}
}
if (tournament.mode === "ko" || tournament.mode === "groups_ko") {
html += `
${renderSectionHeading("KO-Turnierbaum", [
{ href: DRA_GUI_RULE_BYE_URL, kind: "rule", label: "DRA-Regelerklärung zu Freilosen öffnen", title: "DRA-Regeln in der GUI: Freilos (Bye)" },
])}
${renderStaticBracketFallback(tournament)}
Turnierbaum neu laden
CDN-Render aktiv. Der HTML-Fallback wird nur bei Fehlern oder Timeout angezeigt.
`;
}
return html || ``;
}
function renderIOTab() {
return `
Export
JSON herunterladen
JSON in Zwischenablage
Import
Datei importieren
JSON einf\u00fcgen
Eingef\u00fcgtes JSON importieren
`;
}
function formatUpdateCheckedAt(timestamp) {
const value = Number(timestamp || 0);
if (value <= 0) {
return "";
}
try {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(value));
} catch (_) {
return "";
}
}
function getUpdatePanelState(updateStatus) {
if (!updateStatus?.capable) {
return "";
}
const normalizedStatus = normalizeText(updateStatus.status || "").toLowerCase();
if (["available", "current", "checking", "error"].includes(normalizedStatus)) {
return normalizedStatus;
}
return normalizeText(updateStatus.remoteVersion || "") ? "current" : "checking";
}
function renderUpdatePanel() {
const updateStatus = state.updateStatus;
if (!updateStatus?.capable) {
return `
${renderSectionHeading("Script-Update", [
{ href: README_BASE_URL, kind: "tech", label: "README zur Installation öffnen", title: "README: Schnellstart" },
])}
Die Update-Prüfung ist in diesem Kontext nicht verfügbar, weil keine Browser-Fetch-API bereitsteht.
`;
}
const panelState = getUpdatePanelState(updateStatus);
const installedVersion = normalizeText(updateStatus.installedVersion || APP_VERSION) || APP_VERSION;
const remoteVersion = normalizeText(updateStatus.remoteVersion || "");
const checkedAtText = formatUpdateCheckedAt(updateStatus.checkedAt);
const loaderActive = isLoaderRuntimeActive();
let titleText = "GitHub-Version wird geprüft";
let copyText = "Die Versionsprüfung läuft oder es liegt noch kein erfolgreicher GitHub-Abgleich vor.";
if (panelState === "available") {
titleText = loaderActive ? "Neue Version bereit" : "Update verfügbar";
copyText = loaderActive
? `Installiert: v${installedVersion}. Auf GitHub liegt bereits v${remoteVersion}. Da der Loader aktiv ist, reicht ein Reload von play.autodarts.io.`
: `Installiert: v${installedVersion}. Auf GitHub liegt bereits v${remoteVersion}.`;
} else if (panelState === "current") {
titleText = "Version ist aktuell";
copyText = remoteVersion
? `Installiert ist bereits die aktuelle GitHub-Version v${remoteVersion}.`
: `Installierte Version: v${installedVersion}.`;
} else if (panelState === "error") {
titleText = "Update-Prüfung fehlgeschlagen";
copyText = normalizeText(updateStatus.error || "Die GitHub-Version konnte nicht gelesen werden.");
}
if (checkedAtText) {
copyText = `${copyText} ${updateStatus.stale ? "Letzter erfolgreicher Stand" : "Geprüft"}: ${checkedAtText}.`;
}
return `
${renderSectionHeading("Script-Update", [
{ href: README_BASE_URL, kind: "tech", label: "README zur Installation öffnen", title: "README: Schnellstart" },
])}
${escapeHtml(titleText)}
${escapeHtml(copyText)}
${panelState === "checking" ? "Prüfe..." : "Neu prüfen"}
${panelState === "available"
? (loaderActive
? `Neu laden `
: `Update installieren `)
: ""}
Direkt-Install: Runtime Userscript · Empfohlen: Loader
`;
}
function renderSettingsTab() {
const debugEnabled = state.store.settings.debug ? "checked" : "";
const debugReport = buildMatchStartDebugReport(state.store);
const debugReportText = JSON.stringify(debugReport, null, 2);
const hasMatchStartDebugSessions = debugReport.sessionCount > 0;
const debugActionDisabledAttr = hasMatchStartDebugSessions ? "" : "disabled";
const autoLobbyEnabled = state.store.settings.featureFlags.autoLobbyStart ? "checked" : "";
const randomizeKoEnabled = state.store.settings.featureFlags.randomizeKoRound1 ? "checked" : "";
const koDrawLockDefaultEnabled = state.store.settings.featureFlags.koDrawLockDefault !== false ? "checked" : "";
const activeKoDrawLocked = state.store?.tournament?.mode === "ko"
? (state.store?.tournament?.ko?.drawLocked !== false ? "checked" : "")
: "";
const activeKoDrawLockDisabledAttr = state.store?.tournament?.mode === "ko" ? "" : "disabled";
const modeLimitSummary = buildModeParticipantLimitSummary();
const tieBreakProfile = normalizeTieBreakProfile(
state.store?.tournament?.rules?.tieBreakProfile,
TIE_BREAK_PROFILE_PROMOTER_H2H_MINITABLE,
);
const tieBreakLocked = hasRelevantCompletedTieBreakMatch(state.store?.tournament);
const tieBreakDisabledAttr = state.store?.tournament && !tieBreakLocked ? "" : "disabled";
const apiSyncHelpLinks = renderInfoLinks([
{ href: README_API_AUTOMATION_URL, kind: "tech", label: "Erkl\u00e4rung zur API-Halbautomatik \u00f6ffnen", title: "README: API-Halbautomatik" },
{ href: README_INFO_SYMBOLS_URL, kind: "tech", label: "Legende der Info-Symbole \u00f6ffnen", title: "README: Info-Symbole" },
]);
const koDrawHelpLinks = renderInfoLinks([
{ href: README_TOURNAMENT_MODES_URL, kind: "tech", label: "Erkl\u00e4rung zu Turniermodi \u00f6ffnen", title: "README: Turniermodi und Open Draw" },
{ href: DRA_GUI_RULE_OPEN_DRAW_URL, kind: "rule", label: "DRA-Regelerkl\u00e4rung zu Open Draw \u00f6ffnen", title: "DRA-Regeln in der GUI: Open Draw" },
]);
const koDrawLockHelpLinks = renderInfoLinks([
{ href: DRA_GUI_RULE_DRAW_LOCK_URL, kind: "rule", label: "DRA-Regelerkl\u00e4rung zu Draw-Lock \u00f6ffnen", title: "DRA-Regeln in der GUI: Draw-Lock" },
]);
return `
${renderUpdatePanel()}
${renderSectionHeading("Debug und Feature-Flags", [
{ href: README_SETTINGS_URL, kind: "tech", label: "Einstellungen-Dokumentation \u00f6ffnen", title: "README: Einstellungen" },
{ href: README_INFO_SYMBOLS_URL, kind: "tech", label: "Legende der Info-Symbole \u00f6ffnen", title: "README: Info-Symbole" },
])}
Matchstart-Debug kopieren
Matchstart-Debug leeren
Erfasst Vorprüfung, Lobby-Payload, API-Schritte, bullMode-Fallback, vorsichtiges Lobby-Cleanup und Fehlerdetails der letzten Matchstarts.
${hasMatchStartDebugSessions
? `${escapeHtml(debugReportText)} `
: `Noch keine Matchstart-Debugdaten vorhanden. Debug-Mode aktivieren, Match testen und das Protokoll danach hier kopieren.
`}
${renderSectionHeading("Turnierzeit-Prognose", [
{ href: README_TOURNAMENT_CREATE_URL, kind: "tech", label: "Erkl\u00e4rung zur Turnierzeit-Prognose \u00f6ffnen", title: "README: Turnier anlegen" },
{ href: README_SETTINGS_URL, kind: "tech", label: "Einstellungen-Dokumentation \u00f6ffnen", title: "README: Einstellungen" },
])}
Zeitprofil und Board-Anzahl werden direkt im Tab Turnier neben der Prognose gesetzt, damit die Planung ohne Tab-Wechsel angepasst werden kann.
Schnell: z\u00fcgige Abl\u00e4ufe. Normal: ausgewogener Standard. Langsam: konservativer f\u00fcr gemischte Felder und l\u00e4ngere Wechselzeiten.
${renderSectionHeading("KO Draw-Lock (aktives Turnier)", [
{ href: DRA_GUI_RULE_DRAW_LOCK_URL, kind: "rule", label: "DRA-Regelerkl\u00e4rung zu Draw-Lock \u00f6ffnen", title: "DRA-Regeln in der GUI: Draw-Lock" },
])}
Nur f\u00fcr den Modus KO (Straight Knockout) verf\u00fcgbar. Entsperren erfordert einen expliziten Promoter-Override mit Best\u00e4tigung (DRA 6.12.1).
${renderSectionHeading("Promoter Tie-Break-Profil", [
{ href: DRA_GUI_RULE_TIE_BREAK_URL, kind: "rule", label: "DRA-Regelerkl\u00e4rung zum Tie-Break \u00f6ffnen", title: "DRA-Regeln in der GUI: Tie-Break" },
])}
Profil pro Turnier
Promoter H2H + Mini-Tabelle (empfohlen)
Promoter Punkte + LegDiff
Promoter H2H + Mini-Tabelle: Punkte (2/1/0), danach Direktvergleich (2er-Gleichstand), Teilgruppen-Leg-Differenz (3+), Gesamt-Leg-Differenz, Legs gewonnen; verbleibender Gleichstand = „Playoff erforderlich“.
Promoter Punkte + LegDiff: vereinfachte Sortierung \u00fcber Punkte, Gesamt-Leg-Differenz und Legs gewonnen (legacy-kompatibel).
${tieBreakLocked ? `Profil gesperrt: Nach dem ersten abgeschlossenen Gruppen-/Liga-Ergebnis ist keine Profil\u00e4nderung mehr zul\u00e4ssig (DRA 6.16.1).
` : ""}
${renderSectionHeading("DRA Checkliste (nicht automatisierbar)", [
{ href: DRA_GUI_RULE_CHECKLIST_URL, kind: "rule", label: "DRA-Regelerkl\u00e4rung zur Checkliste \u00f6ffnen", title: "DRA-Regeln in der GUI: Checkliste" },
])}
Start-/Wurfreihenfolge und Bull-Off-Entscheidungen werden durch den Spielleiter vor Ort best\u00e4tigt.
Practice/Anspielzeit und Board-Etikette werden organisatorisch durchgesetzt.
Tie-Break-Entscheidungen bei verbleibendem Gleichstand erfolgen als Promoter-Entscheidung.
Unklare Sonderf\u00e4lle werden dokumentiert und manuell entschieden, bevor der Turnierfortschritt fortgesetzt wird.
${renderSectionHeading("Regelbasis und Limits", [
{ href: DRA_GUI_RULE_PARTICIPANT_LIMITS_URL, kind: "rule", label: "DRA-Regelerkl\u00e4rung zu Limits \u00f6ffnen", title: "DRA-Regeln in der GUI: Teilnehmerlimits" },
])}
Aktive Modus-Limits: ${escapeHtml(modeLimitSummary)}.
Die DRA-Regeln setzen kein fixes globales Teilnehmermaximum. Die Grenzen oben sind bewusst f\u00fcr faire Turnierdauer und stabile Darstellung gesetzt.
${renderSectionHeading("Storage", [
{ href: README_BASE_URL, kind: "tech", label: "Hinweise zu Storage und Import \u00f6ffnen", title: "README: Import, Migration und Persistenz" },
])}
${escapeHtml(STORAGE_KEY)}, schemaVersion ${STORAGE_SCHEMA_VERSION}
`;
}
function ensureHost() {
let host = document.getElementById(UI_HOST_ID);
if (!host) {
host = document.createElement("div");
host.id = UI_HOST_ID;
document.documentElement.appendChild(host);
}
if (!(host instanceof HTMLElement)) {
throw new Error("ATA host element not available.");
}
state.host = host;
if (!host.shadowRoot) {
host.attachShadow({ mode: "open" });
}
state.shadowRoot = host.shadowRoot;
}
function renderShell() {
if (!state.shadowRoot) {
return;
}
state.shadowRoot.innerHTML = buildShellHtml();
bindUiHandlers();
syncLoaderMenuUpdateIndicator();
if (state.activeTab === "view") {
queueBracketRender();
syncBracketFallbackVisibility();
}
}
async function hydrateStoredUpdateStatus() {
setUpdateStatus(readStoredUpdateStatus({
windowRef: window,
installedVersion: APP_VERSION,
}));
}
function refreshUpdateStatus(options = {}) {
const force = Boolean(options.force);
const announce = Boolean(options.announce);
if (!state.updateStatus.capable) {
return Promise.resolve(state.updateStatus);
}
if (state.updateCheckPromise) {
return state.updateCheckPromise;
}
if (!force && !shouldRefreshUpdateStatus(state.updateStatus)) {
return Promise.resolve(state.updateStatus);
}
setUpdateStatus({
status: "checking",
error: "",
stale: Boolean(state.updateStatus.stale && state.updateStatus.checkedAt > 0),
});
const updatePromise = resolveLatestUpdateStatus({
windowRef: window,
installedVersion: APP_VERSION,
force,
}).then((nextStatus) => {
setUpdateStatus(nextStatus);
if (announce) {
if (nextStatus.status === "available") {
const actionText = isLoaderRuntimeActive()
? `Neue Version gefunden: ${APP_VERSION} -> ${nextStatus.remoteVersion}. Ein Reload reicht, da der Loader aktiv ist.`
: `Neue Version gefunden: ${APP_VERSION} -> ${nextStatus.remoteVersion}.`;
setNotice("info", actionText, 4200);
} else if (nextStatus.status === "current") {
setNotice("success", `Kein neueres Update gefunden. Aktuell installiert: ${APP_VERSION}.`, 2800);
} else if (nextStatus.status === "error" || nextStatus.error) {
setNotice("error", nextStatus.error || "Update-Prüfung fehlgeschlagen.", 4200);
}
}
return nextStatus;
}).finally(() => {
state.updateCheckPromise = null;
});
state.updateCheckPromise = updatePromise;
return updatePromise;
}
function installAvailableUpdate() {
if (!state.updateStatus?.available) {
return false;
}
if (isLoaderRuntimeActive()) {
return reloadForLoaderUpdate(window);
}
return openUserscriptInstall(window);
}
function bindUiHandlers() {
const shadow = state.shadowRoot;
if (!shadow) {
return;
}
shadow.querySelectorAll("[data-tab]").forEach((button) => {
button.addEventListener("click", () => {
const tabId = button.getAttribute("data-tab");
if (!TAB_IDS.includes(tabId)) {
return;
}
state.activeTab = tabId;
state.store.ui.activeTab = tabId;
schedulePersist();
renderShell();
});
});
shadow.querySelectorAll("[data-action='set-matches-sort']").forEach((button) => {
button.addEventListener("click", () => {
const sortMode = sanitizeMatchesSortMode(button.getAttribute("data-sort-mode"), MATCH_SORT_MODE_READY_FIRST);
if (state.store.ui.matchesSortMode === sortMode) {
return;
}
state.store.ui.matchesSortMode = sortMode;
schedulePersist();
renderShell();
});
});
const createForm = shadow.getElementById("ata-create-form");
if (createForm instanceof HTMLFormElement) {
syncCreateFormDependencies(createForm);
refreshCreateFormDurationEstimate(createForm);
const handleDraftInputChange = (event) => {
const target = event?.target;
const fieldName = target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement
? normalizeText(target.name || "")
: "";
if (fieldName === "tournamentTimeProfile" && target instanceof HTMLSelectElement) {
const profileId = sanitizeTournamentTimeProfile(
target.value,
TOURNAMENT_TIME_PROFILE_NORMAL,
);
if (state.store.settings.tournamentTimeProfile !== profileId) {
state.store.settings.tournamentTimeProfile = profileId;
schedulePersist();
}
}
if (isCreateDraftPresetField(fieldName)) {
setCreateFormPresetValue(createForm, X01_PRESET_CUSTOM);
}
syncCreateFormDependencies(createForm);
updateCreateDraftFromForm(createForm, true);
refreshCreateFormDurationEstimate(createForm);
};
createForm.addEventListener("input", handleDraftInputChange);
createForm.addEventListener("change", handleDraftInputChange);
createForm.addEventListener("submit", (event) => {
event.preventDefault();
handleCreateTournament(createForm);
});
const applyPresetButton = createForm.querySelector("[data-action='apply-selected-preset']");
if (applyPresetButton instanceof HTMLButtonElement) {
applyPresetButton.addEventListener("click", () => {
applySelectedPresetToCreateForm(createForm);
});
}
}
shadow.querySelectorAll("[data-action='set-duration-time-profile']").forEach((select) => {
if (!(select instanceof HTMLSelectElement)) {
return;
}
select.addEventListener("change", () => {
const profileId = sanitizeTournamentTimeProfile(
select.value,
TOURNAMENT_TIME_PROFILE_NORMAL,
);
if (state.store.settings.tournamentTimeProfile === profileId) {
return;
}
state.store.settings.tournamentTimeProfile = profileId;
schedulePersist();
renderShell();
setNotice("info", `Turnierzeit-Profil: ${getTournamentTimeProfileMeta(profileId).label}.`, 2200);
});
});
shadow.querySelectorAll("[data-action='set-duration-board-count']").forEach((field) => {
if (!(field instanceof HTMLInputElement)) {
return;
}
field.addEventListener("change", () => {
const tournament = state.store.tournament;
if (!tournament) {
return;
}
const nextBoardCount = sanitizeTournamentBoardCount(
field.value,
tournament?.duration?.boardCount,
);
if (!tournament.duration || typeof tournament.duration !== "object") {
tournament.duration = { boardCount: nextBoardCount };
} else if (tournament.duration.boardCount === nextBoardCount) {
return;
} else {
tournament.duration.boardCount = nextBoardCount;
}
tournament.updatedAt = nowIso();
schedulePersist();
renderShell();
});
});
shadow.querySelectorAll("[data-action='toggle-duration-estimate-visibility']").forEach((button) => {
button.addEventListener("click", () => {
state.store.ui.durationEstimateVisible = state.store.ui.durationEstimateVisible === false;
schedulePersist();
renderShell();
});
});
const shuffleParticipantsButton = shadow.querySelector("[data-action='shuffle-participants']");
if (shuffleParticipantsButton && createForm instanceof HTMLFormElement) {
shuffleParticipantsButton.addEventListener("click", () => handleShuffleParticipants(createForm));
}
shadow.querySelectorAll("[data-action='close-drawer']").forEach((button) => {
button.addEventListener("click", () => closeDrawer());
});
shadow.querySelectorAll("[data-action='save-match']").forEach((button) => {
button.addEventListener("click", () => {
const matchId = button.getAttribute("data-match-id");
if (matchId) {
handleSaveMatchResult(matchId);
}
});
});
shadow.querySelectorAll("[data-action='start-match']").forEach((button) => {
button.addEventListener("click", () => {
const matchId = button.getAttribute("data-match-id");
if (!matchId) {
return;
}
handleStartMatch(matchId).catch((error) => {
logError("api", "Start-match handler failed unexpectedly.", error);
setNotice("error", "Matchstart ist unerwartet fehlgeschlagen.");
});
});
});
const resetButton = shadow.querySelector("[data-action='reset-tournament']");
if (resetButton) {
resetButton.addEventListener("click", () => {
handleResetTournament();
});
}
const exportFileButton = shadow.querySelector("[data-action='export-file']");
if (exportFileButton) {
exportFileButton.addEventListener("click", () => handleExportFile());
}
const exportClipboardButton = shadow.querySelector("[data-action='export-clipboard']");
if (exportClipboardButton) {
exportClipboardButton.addEventListener("click", () => handleExportClipboard());
}
const importTextButton = shadow.querySelector("[data-action='import-text']");
if (importTextButton) {
importTextButton.addEventListener("click", () => handleImportFromTextarea());
}
const fileInput = shadow.getElementById("ata-import-file");
if (fileInput instanceof HTMLInputElement) {
fileInput.addEventListener("change", () => handleImportFromFile(fileInput));
}
const debugToggle = shadow.getElementById("ata-setting-debug");
if (debugToggle instanceof HTMLInputElement) {
debugToggle.addEventListener("change", () => {
state.store.settings.debug = debugToggle.checked;
schedulePersist();
setNotice("success", `Debug-Mode ${debugToggle.checked ? "aktiviert" : "deaktiviert"}.`, 1800);
});
}
const copyMatchStartDebugButton = shadow.querySelector("[data-action='copy-matchstart-debug']");
if (copyMatchStartDebugButton instanceof HTMLButtonElement) {
copyMatchStartDebugButton.addEventListener("click", async () => {
try {
const report = buildMatchStartDebugReport(state.store);
await navigator.clipboard.writeText(JSON.stringify(report, null, 2));
setNotice("success", "Matchstart-Debug in Zwischenablage kopiert.", 2200);
} catch (error) {
setNotice("error", "Matchstart-Debug konnte nicht kopiert werden.");
logWarn("debug", "Clipboard write for matchstart debug failed.", error);
}
});
}
const clearMatchStartDebugButton = shadow.querySelector("[data-action='clear-matchstart-debug']");
if (clearMatchStartDebugButton instanceof HTMLButtonElement) {
clearMatchStartDebugButton.addEventListener("click", () => {
clearMatchStartDebugSessions(state.store);
schedulePersist();
renderShell();
setNotice("success", "Matchstart-Debug wurde geleert.", 1800);
});
}
const autoLobbyToggle = shadow.getElementById("ata-setting-autolobby");
if (autoLobbyToggle instanceof HTMLInputElement) {
autoLobbyToggle.addEventListener("change", () => {
state.store.settings.featureFlags.autoLobbyStart = autoLobbyToggle.checked;
if (!autoLobbyToggle.checked) {
state.apiAutomation.authBackoffUntil = 0;
}
schedulePersist();
setNotice("info", `Auto-Lobby + API-Sync: ${autoLobbyToggle.checked ? "ON" : "OFF"}.`, 2200);
if (autoLobbyToggle.checked) {
syncPendingApiMatches().catch((error) => {
logWarn("api", "Immediate sync after toggle failed.", error);
});
}
});
}
const randomizeKoToggle = shadow.getElementById("ata-setting-randomize-ko");
if (randomizeKoToggle instanceof HTMLInputElement) {
randomizeKoToggle.addEventListener("change", () => {
state.store.settings.featureFlags.randomizeKoRound1 = randomizeKoToggle.checked;
state.store.ui.createDraft = normalizeCreateDraft({
...state.store.ui.createDraft,
randomizeKoRound1: randomizeKoToggle.checked,
}, state.store.settings);
schedulePersist();
setNotice("info", `KO-Erstrunden-Mix: ${randomizeKoToggle.checked ? "ON" : "OFF"}.`, 2200);
if (state.activeTab === "tournament" && !state.store.tournament) {
renderShell();
}
});
}
const koDrawLockDefaultToggle = shadow.getElementById("ata-setting-ko-draw-lock-default");
if (koDrawLockDefaultToggle instanceof HTMLInputElement) {
koDrawLockDefaultToggle.addEventListener("change", () => {
state.store.settings.featureFlags.koDrawLockDefault = koDrawLockDefaultToggle.checked;
schedulePersist();
setNotice("info", `KO Draw-Lock (Standard): ${koDrawLockDefaultToggle.checked ? "ON" : "OFF"}.`, 2200);
});
}
const koDrawLockedToggle = shadow.getElementById("ata-setting-ko-draw-locked");
if (koDrawLockedToggle instanceof HTMLInputElement) {
koDrawLockedToggle.addEventListener("change", () => {
const targetDrawLocked = koDrawLockedToggle.checked;
let result = null;
if (!targetDrawLocked) {
const pendingOverride = getPendingDrawUnlockOverrideForTournament(state.store?.tournament?.id);
if (pendingOverride?.token) {
const confirmed = window.confirm(
"DRA 6.12.1 Hinweis: Das Entsperren des Draw-Lock darf nur als bewusster Promoter-Override erfolgen. Möchten Sie jetzt ausdrücklich entsperren?",
);
if (!confirmed) {
setNotice("info", "KO Draw-Lock bleibt aktiv. Entsperren wurde nicht bestätigt.", 2600);
renderShell();
return;
}
result = setTournamentKoDrawLocked(false, {
confirmOverrideToken: pendingOverride.token,
});
} else {
result = setTournamentKoDrawLocked(false);
}
} else {
result = setTournamentKoDrawLocked(true);
}
if (!result.ok) {
const reasonCode = normalizeText(result.reasonCode || "");
if (reasonCode === "draw_unlock_requires_override") {
setNotice(
"info",
"Entsperren blockiert. Bitte den Schalter erneut auf AUS stellen und den Promoter-Override explizit bestätigen (DRA 6.12.1).",
5200,
);
} else {
setNotice("error", result.message || "KO Draw-Lock konnte nicht gesetzt werden.");
}
return;
}
if (result.changed) {
setNotice("success", `KO Draw-Lock ${targetDrawLocked ? "aktiviert" : "deaktiviert"}.`, 1800);
}
});
}
const tieBreakSelect = shadow.getElementById("ata-setting-tiebreak");
if (tieBreakSelect instanceof HTMLSelectElement) {
tieBreakSelect.addEventListener("change", () => {
const result = setTournamentTieBreakProfile(tieBreakSelect.value);
if (!result.ok) {
const reasonCode = normalizeText(result.reasonCode || "");
if (reasonCode === "tie_break_locked") {
setNotice("info", result.message || "Tie-Break-Profil ist nach dem ersten relevanten Ergebnis gesperrt.", 5200);
} else {
setNotice("error", result.message || "Tie-Break-Profil konnte nicht gesetzt werden.");
}
return;
}
if (result.changed) {
setNotice("success", "Tie-Break-Profil aktualisiert.", 1800);
}
});
}
const checkUpdateButton = shadow.querySelector("[data-action='check-update']");
if (checkUpdateButton instanceof HTMLButtonElement) {
checkUpdateButton.addEventListener("click", () => {
refreshUpdateStatus({
force: true,
announce: true,
}).catch((error) => {
logWarn("update", "Manual update check failed unexpectedly.", error);
setNotice("error", "Update-Prüfung ist fehlgeschlagen.", 4200);
});
});
}
const installUpdateButton = shadow.querySelector("[data-action='install-update']");
if (installUpdateButton instanceof HTMLButtonElement) {
installUpdateButton.addEventListener("click", () => {
const opened = installAvailableUpdate();
if (!opened) {
setNotice("error", "Update konnte nicht geöffnet werden.", 4200);
return;
}
setNotice("info", "Userscript-Quelle wurde zum Update geöffnet.", 3200);
});
}
const reloadUpdateButton = shadow.querySelector("[data-action='reload-update']");
if (reloadUpdateButton instanceof HTMLButtonElement) {
reloadUpdateButton.addEventListener("click", () => {
const reloaded = installAvailableUpdate();
if (!reloaded) {
setNotice("error", "Reload für Loader-Update konnte nicht ausgelöst werden.", 4200);
}
});
}
const retryBracketButton = shadow.querySelector("[data-action='retry-bracket']");
if (retryBracketButton) {
retryBracketButton.addEventListener("click", () => queueBracketRender(true));
}
const drawer = shadow.querySelector(".ata-drawer");
if (drawer) {
drawer.addEventListener("keydown", handleDrawerKeydown);
}
}
function handleDrawerKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
closeDrawer();
return;
}
if (event.key !== "Tab" || !state.drawerOpen) {
return;
}
const drawer = state.shadowRoot?.querySelector(".ata-drawer");
if (!drawer) {
return;
}
const focusables = Array.from(drawer.querySelectorAll(
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
)).filter((element) => !element.hasAttribute("disabled"));
if (!focusables.length) {
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const current = drawer.getRootNode().activeElement;
if (event.shiftKey && current === first) {
event.preventDefault();
last.focus();
return;
}
if (!event.shiftKey && current === last) {
event.preventDefault();
first.focus();
}
}
function openDrawer() {
state.lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
state.drawerOpen = true;
renderShell();
const firstInteractive = state.shadowRoot?.querySelector(".ata-drawer button, .ata-drawer input, .ata-drawer select, .ata-drawer textarea");
if (firstInteractive instanceof HTMLElement) {
firstInteractive.focus();
}
}
function closeDrawer() {
state.drawerOpen = false;
renderShell();
if (state.lastFocused instanceof HTMLElement) {
state.lastFocused.focus();
}
}
function toggleDrawer() {
if (state.drawerOpen) {
closeDrawer();
} else {
openDrawer();
}
}
function isCreateDraftPresetField(fieldName) {
return [
"mode",
"bestOfLegs",
"startScore",
"x01InMode",
"x01OutMode",
"x01BullMode",
"x01BullOffMode",
"x01MaxRounds",
].includes(normalizeText(fieldName || ""));
}
function setCreateFormPresetValue(form, presetId) {
if (!(form instanceof HTMLFormElement)) {
return;
}
const presetInput = form.querySelector("#ata-x01-preset");
const presetSelect = form.querySelector("#ata-preset-select");
if (!(presetInput instanceof HTMLInputElement)) {
return;
}
const normalizedPreset = sanitizeX01Preset(presetId, X01_PRESET_CUSTOM);
presetInput.value = normalizedPreset;
if (presetSelect instanceof HTMLSelectElement) {
presetSelect.value = normalizedPreset;
}
}
function refreshCreateFormPresetBadge(form) {
if (!(form instanceof HTMLFormElement)) {
return;
}
const presetInput = form.querySelector("#ata-x01-preset");
const presetBadge = form.querySelector(".ata-preset-pill");
if (!(presetInput instanceof HTMLInputElement) || !(presetBadge instanceof HTMLElement)) {
return;
}
const formData = new FormData(form);
const draft = normalizeCreateDraft(readCreateDraftInput(formData), state.store.settings);
const presetId = getAppliedCreatePresetId(draft);
presetInput.value = presetId;
presetBadge.textContent = `Preset aktiv: ${getCreatePresetLabel(presetId)}`;
}
function syncCreateFormDependencies(form) {
if (!(form instanceof HTMLFormElement)) {
return;
}
const bullOffSelect = form.querySelector("#ata-x01-bulloff");
const bullModeSelect = form.querySelector("#ata-x01-bullmode");
if (!(bullOffSelect instanceof HTMLSelectElement) || !(bullModeSelect instanceof HTMLSelectElement)) {
refreshCreateFormPresetBadge(form);
return;
}
const disableBullMode = normalizeText(bullOffSelect.value) === "Off";
bullModeSelect.disabled = disableBullMode;
bullModeSelect.title = disableBullMode
? "Bull-Modus ist bei Bull-off = Off ohne Wirkung und daher schreibgesch\u00fctzt."
: "";
let hiddenBullMode = form.querySelector("#ata-x01-bullmode-hidden");
if (disableBullMode) {
if (!(hiddenBullMode instanceof HTMLInputElement)) {
hiddenBullMode = document.createElement("input");
hiddenBullMode.type = "hidden";
hiddenBullMode.id = "ata-x01-bullmode-hidden";
hiddenBullMode.name = "x01BullMode";
bullModeSelect.insertAdjacentElement("afterend", hiddenBullMode);
}
hiddenBullMode.value = sanitizeX01BullMode(bullModeSelect.value);
} else if (hiddenBullMode instanceof HTMLElement) {
hiddenBullMode.remove();
}
refreshCreateFormPresetBadge(form);
}
function applySelectedPresetToCreateForm(form) {
if (!(form instanceof HTMLFormElement)) {
return;
}
const presetSelect = form.querySelector("#ata-preset-select");
if (!(presetSelect instanceof HTMLSelectElement)) {
return;
}
const presetId = sanitizeX01Preset(presetSelect.value, X01_PRESET_CUSTOM);
const preset = getCreatePresetDefinition(presetId);
if (!preset) {
setCreateFormPresetValue(form, X01_PRESET_CUSTOM);
syncCreateFormDependencies(form);
updateCreateDraftFromForm(form, true);
refreshCreateFormDurationEstimate(form);
setNotice("info", "Individuelles Preset bleibt aktiv; Felder wurden nicht überschrieben.", 2400);
return;
}
const apply = preset.apply;
const assignments = [
["#ata-mode", apply.mode],
["#ata-bestof", String(apply.bestOfLegs)],
["#ata-startscore", String(apply.startScore)],
["#ata-x01-inmode", apply.x01InMode],
["#ata-x01-outmode", apply.x01OutMode],
["#ata-x01-bullmode", apply.x01BullMode],
["#ata-x01-bulloff", apply.x01BullOffMode],
["#ata-x01-maxrounds", String(apply.x01MaxRounds)],
];
assignments.forEach(([selector, value]) => {
const field = form.querySelector(selector);
if (field instanceof HTMLInputElement || field instanceof HTMLSelectElement || field instanceof HTMLTextAreaElement) {
field.value = value;
}
});
setCreateFormPresetValue(form, preset.id);
syncCreateFormDependencies(form);
updateCreateDraftFromForm(form, true);
refreshCreateFormDurationEstimate(form);
setNotice("info", `Preset „${preset.label}“ wurde auf alle Turnierfelder angewendet.`, 2600);
}
function readCreateDraftInput(formData) {
return {
name: formData.get("name"),
mode: formData.get("mode"),
bestOfLegs: formData.get("bestOfLegs"),
startScore: formData.get("startScore"),
x01Preset: formData.get("x01Preset"),
x01InMode: formData.get("x01InMode"),
x01OutMode: formData.get("x01OutMode"),
x01BullMode: formData.get("x01BullMode"),
x01MaxRounds: formData.get("x01MaxRounds"),
x01BullOffMode: formData.get("x01BullOffMode"),
boardCount: formData.get("boardCount"),
participantsText: String(formData.get("participants") || ""),
randomizeKoRound1: formData.get("randomizeKoRound1") !== null,
enableThirdPlaceMatch: formData.get("enableThirdPlaceMatch") !== null,
};
}
function updateCreateDraftFromForm(form, persist = true) {
if (!(form instanceof HTMLFormElement)) {
return;
}
const formData = new FormData(form);
const nextDraft = normalizeCreateDraft(readCreateDraftInput(formData), state.store.settings);
const currentDraft = state.store.ui.createDraft || {};
const changed = JSON.stringify(nextDraft) !== JSON.stringify(currentDraft);
if (!changed) {
return;
}
state.store.ui.createDraft = nextDraft;
if (persist) {
schedulePersist();
}
}
function refreshCreateFormDurationEstimate(form) {
if (!(form instanceof HTMLFormElement)) {
return;
}
const estimateHost = form.querySelector("#ata-create-duration-estimate");
if (!(estimateHost instanceof HTMLElement)) {
return;
}
const formData = new FormData(form);
const draft = normalizeCreateDraft(readCreateDraftInput(formData), state.store.settings);
const estimate = estimateTournamentDurationFromDraft(draft, state.store.settings);
estimateHost.innerHTML = renderTournamentDurationEstimate(estimate, {
visible: state.store?.ui?.durationEstimateVisible !== false,
});
}
function handleShuffleParticipants(form) {
if (!(form instanceof HTMLFormElement)) {
return;
}
const participantField = form.querySelector("#ata-participants");
if (!(participantField instanceof HTMLTextAreaElement)) {
return;
}
const participants = parseParticipantLines(participantField.value);
if (participants.length < 2) {
setNotice("info", "Mindestens zwei Teilnehmer zum Mischen eingeben.", 2200);
return;
}
const shuffledNames = shuffleArray(participants.map((participant) => participant.name));
participantField.value = shuffledNames.join("\n");
updateCreateDraftFromForm(form, true);
refreshCreateFormDurationEstimate(form);
setNotice("success", "Teilnehmer wurden zuf\u00e4llig gemischt.", 1800);
}
function handleCreateTournament(form) {
syncCreateFormDependencies(form);
const formData = new FormData(form);
const draft = normalizeCreateDraft(readCreateDraftInput(formData), state.store.settings);
state.store.ui.createDraft = draft;
const participants = parseParticipantLines(formData.get("participants"));
const config = {
name: draft.name,
mode: draft.mode,
bestOfLegs: draft.bestOfLegs,
startScore: draft.startScore,
x01Preset: draft.x01Preset,
x01InMode: draft.x01InMode,
x01OutMode: draft.x01OutMode,
x01BullMode: draft.x01BullMode,
x01MaxRounds: draft.x01MaxRounds,
x01BullOffMode: draft.x01BullOffMode,
lobbyVisibility: "private",
boardCount: draft.boardCount,
randomizeKoRound1: draft.randomizeKoRound1,
enableThirdPlaceMatch: draft.enableThirdPlaceMatch,
koDrawLocked: state.store.settings.featureFlags.koDrawLockDefault !== false,
participants,
};
const result = createTournamentSession(config);
if (!result.ok) {
setNotice("error", result.message || "Turnier konnte nicht erstellt werden.");
return;
}
setNotice("success", "Turnier wurde erstellt.");
}
function getMatchFieldElement(shadow, fieldName, matchId) {
const candidates = Array.from(shadow.querySelectorAll(`[data-field="${fieldName}"]`));
return candidates.find((candidate) => candidate.getAttribute("data-match-id") === matchId) || null;
}
function handleSaveMatchResult(matchId) {
const shadow = state.shadowRoot;
if (!shadow) {
return;
}
const tournament = state.store.tournament;
if (!tournament) {
return;
}
const match = findMatch(tournament, matchId);
if (!match) {
setNotice("error", "Match nicht gefunden.");
return;
}
const editability = getMatchEditability(tournament, match);
if (!editability.editable) {
setNotice("error", editability.reason || "Match ist nicht freigeschaltet.");
return;
}
const legsP1Input = getMatchFieldElement(shadow, "legs-p1", matchId);
const legsP2Input = getMatchFieldElement(shadow, "legs-p2", matchId);
if (!(legsP1Input instanceof HTMLInputElement) || !(legsP2Input instanceof HTMLInputElement)) {
return;
}
const p1Legs = clampInt(legsP1Input.value, 0, 0, 99);
const p2Legs = clampInt(legsP2Input.value, 0, 0, 99);
if (p1Legs === p2Legs) {
setNotice("error", "Ung\u00fcltiges Ergebnis: Bei Best-of ist kein Gleichstand m\u00f6glich.");
return;
}
const winnerId = p1Legs > p2Legs ? match.player1Id : match.player2Id;
const result = updateMatchResult(matchId, winnerId, {
p1: p1Legs,
p2: p2Legs,
}, "manual");
if (result.ok) {
setNotice("success", "Match gespeichert.", 1800);
} else {
setNotice("error", result.message || "Match konnte nicht gespeichert werden.");
}
}
function handleResetTournament() {
const confirmed = window.confirm("Soll das Turnier wirklich gel\u00f6scht werden? Dieser Schritt kann nicht r\u00fcckg\u00e4ngig gemacht werden.");
if (!confirmed) {
return;
}
resetTournamentSession();
setNotice("success", "Turnier wurde gel\u00f6scht.");
}
function exportDataPayload() {
return {
schemaVersion: STORAGE_SCHEMA_VERSION,
exportedAt: nowIso(),
tournament: state.store.tournament,
};
}
function handleExportFile() {
const payload = exportDataPayload();
const text = JSON.stringify(payload, null, 2);
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `ata-export-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
setNotice("success", "JSON-Datei exportiert.", 2000);
}
async function handleExportClipboard() {
try {
const payload = exportDataPayload();
const text = JSON.stringify(payload, null, 2);
await navigator.clipboard.writeText(text);
setNotice("success", "JSON in Zwischenablage kopiert.", 2000);
} catch (error) {
setNotice("error", "Kopieren in Zwischenablage fehlgeschlagen.");
logWarn("io", "Clipboard write failed.", error);
}
}
function handleImportFromTextarea() {
const textarea = state.shadowRoot?.getElementById("ata-import-text");
if (!(textarea instanceof HTMLTextAreaElement)) {
return;
}
try {
const parsed = JSON.parse(textarea.value);
const result = importTournamentPayload(parsed);
if (result.ok) {
setNotice("success", "JSON erfolgreich importiert.");
} else {
setNotice("error", result.message || "Import fehlgeschlagen.");
}
} catch (error) {
setNotice("error", "JSON konnte nicht geparst werden.");
logWarn("io", "Import parse failed.", error);
}
}
function handleImportFromFile(fileInput) {
const file = fileInput.files && fileInput.files[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(String(reader.result || "{}"));
const result = importTournamentPayload(parsed);
if (result.ok) {
setNotice("success", "Datei erfolgreich importiert.");
} else {
setNotice("error", result.message || "Datei konnte nicht importiert werden.");
}
} catch (error) {
setNotice("error", "Datei enth\u00e4lt kein g\u00fcltiges JSON.");
logWarn("io", "File import parse failed.", error);
}
};
reader.onerror = () => {
setNotice("error", "Datei konnte nicht gelesen werden.");
};
reader.readAsText(file);
}
// Runtime layer: bootstrap wiring only.
// Runtime layer: bootstrap wiring only.
async function init() {
await loadPersistedStore();
await hydrateStoredUpdateStatus();
state.runtimeStatusSignature = runtimeStatusSignature();
ensureHost();
renderShell();
removeMatchReturnShortcut();
renderHistoryImportButton();
initEventBridge();
installRouteHooks();
startAutoDetectionObserver();
setupRuntimeApi();
addInterval(() => {
syncPendingApiMatches().catch((error) => {
logWarn("api", "Background sync loop failed.", error);
});
}, API_SYNC_INTERVAL_MS);
addInterval(() => {
refreshRuntimeStatusUi();
syncLoaderMenuUpdateIndicator();
renderHistoryImportButton();
}, 1200);
addInterval(() => {
if (document.visibilityState === "hidden") {
return;
}
refreshUpdateStatus({
force: false,
announce: false,
}).catch((error) => {
logWarn("update", "Background update check failed.", error);
});
}, UPDATE_AUTO_CHECK_INTERVAL_MS);
addListener(document, "visibilitychange", () => {
if (document.visibilityState === "hidden") {
return;
}
refreshUpdateStatus({
force: false,
announce: false,
}).catch((error) => {
logWarn("update", "Visibility-triggered update check failed.", error);
});
});
refreshUpdateStatus({
force: false,
announce: false,
}).catch((error) => {
logWarn("update", "Initial update check failed.", error);
});
state.ready = true;
window.dispatchEvent(new CustomEvent(READY_EVENT, {
detail: {
version: APP_VERSION,
},
}));
logDebug("runtime", "ATA runtime initialized.");
}
init().catch((error) => {
logError("runtime", "Initialization failed.", error);
});
})();