// ==UserScript== // @name hasa 1.1.5 forschungsmodul komplett - horizon assistant // @namespace horiversum.hasa // @version 1.1 // @description Persönlicher Ascension-Assistent für Horizon // @match https://horiversum.org/game/main/main.php* // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @connect techtree.kommandobasis.eu // ==/UserScript== /* ========================================================== HASA – Horizon Ascension Assistant Version : 1.1 Status : Forschungsmodul – allgemeine Typ- und Iststandsauswertung Build : 2026-08-25 Änderungen: - 1.1: Forschungsmodul als allgemeiner Mechanismus abgeschlossen - STAN-XML ist für bekannte Objekt-IDs die maßgebliche Typquelle - vor „Unbekannt“ wird immer der lokale STAN-Bestand geprüft - Logistik und weitere nicht namensbasierte Forschungen werden dadurch korrekt eingeordnet - Forschungs-Iststand wird vollständig aus der sichtbaren Horizon-Forschungsseite neu aufgebaut - nach Forschungsabschluss/F5/erneutem Öffnen wird der Soll/Ist-Vergleich ohne Sonderfall aktualisiert - Ascension-Reset aus 1.0.3l bleibt erhalten - HASA passt seine Breite an den verfügbaren rechten Bildschirmbereich an - auf kleinen Displays wird HASA horizontal scrollbar statt Horizon zu überdecken - 1.0.3l: Horizon-Seitenstand ist je Bereich autoritativ; alte Forschungs-/Gebäudewerte werden vor dem Scan entfernt - damit bleiben Werte aus dem Leben vor einer Ascension nicht als erledigt gespeichert - SOLARENERGYTECH / Solarenergietechnik wird verbindlich als Forschung eingeordnet - J bleibt J: UI-Korrektur in 1.0.3j integriert - Astrogation und Terraforming werden sicher als Forschung eingeordnet - Forschungsplan-Liste wieder ein-/ausklappbar - Forschungsseite wird bei jedem HASA-Seitenscan vollständig neu als Iststand eingelesen - zusätzlicher Horizon-Fallback liest Zeilen wie „Physik 16 (physics - Forschungsstufe 16)“ - technische Horizon-ID wird dabei mitgespeichert (z. B. PHYSICS, PSYCHOLOGY, REFINERYTECH) - F5 bzw. erneutes Öffnen/Ändern der Forschungsseite bewertet den Plan danach sofort neu - Forschungsplaner neu begonnen: keine rekursive Sammlung in dieser Teststufe - nur die direkten Mindestanforderungen des ausgewählten Ziels werden gelesen - direkte Anforderungen werden sofort gegen den lokalen Horizon-Iststand geprüft - erfüllte Anforderungen verschwinden in der reduzierten Ansicht - keine Untervoraussetzungen, Reverse-Pfade oder Maximalstufen in dieser Teststufe - erfüllte Voraussetzungen beenden ihren Zweig sofort; keine Rekursion unter bereits vorhandene Gebäude/Forschungen - bereits erfüllte Zweige werden beim TechTree-Import nicht mehr nachgeladen - Namen mit der Endung „Technik“ werden verbindlich als Forschung eingeordnet - sichtbarer Debugbereich bleibt erhalten - STAN-Kategorien „Schwarm“ und „Arkane“ im Ziel-Explorer ausblenden - Fremdrassen-Daten bleiben im lokalen STAN-Bestand erhalten und können später für Kampfanalyse genutzt werden - Forschungs- und Gebäudestände weiterhin nur von der sichtbar geöffneten Horizon-Seite lesen - nach dem Öffnen eines Horizon-Unterbereichs automatisch erneut einlesen - Forschungsplanung danach ohne weiteren HASA-Klick aktualisieren - keine Horizon-Seiten im Hintergrund abrufen - Debugprotokoll für Auswahl und Sammlung der TechTree-Anforderungen - protokolliert Übernahme, Ablehnung, Zusammenführung und Rekursionssprünge Idee & Konzept: Styl Programmierung: ChatGPT Grundsatz: HASA informiert, analysiert und erinnert. HASA spielt nicht. Speicherung: Alle Einstellungen werden lokal im Tampermonkey-Speicher (GM_getValue / GM_setValue) gespeichert. Fallback: Browser-localStorage, falls GM-Speicher nicht verfügbar ist. ========================================================== */ (function () { 'use strict'; // Fremdrassen: nicht als erreichbare Spielerziele im Explorer anzeigen. // Die Datensätze bleiben bewusst gespeichert, damit sie später z. B. // für einen Kampfsimulator oder Flottenvergleich genutzt werden können. const STAN_EXPLORER_AUSGEBLENDETE_KATEGORIEN = ['schwarm', 'arkane']; function istStanExplorerKategorieAusgeblendet(kategorie) { const normalisiert = normalisiereTechTreeText(kategorie).toLowerCase(); return STAN_EXPLORER_AUSGEBLENDETE_KATEGORIEN.includes(normalisiert); } const HASA_SICHERE_FORSCHUNGS_IDS = new Set([ 'ASTROGATION', 'TERRAFORMINGTECH', 'TERRAFORMING', 'PHYSICS', 'PSYCHOLOGY', 'NUCLEARPHYSICS', 'SOLARENERGYTECH' ]); const HASA_VERSION = '1.1 – Forschungsmodul komplett · poliert'; const HASA_BUILD_LABEL = '1.1-poliert-stufenbereiche-2026-08-27'; /* * hasa-Positionswerte ab 0.7 Beta * * Die Position der hasa-Box hängt NICHT mehr vom Text „Ascension“ ab. * Ascension entscheidet nur noch über den INHALT der Box. * * Grundlage ist der feste Horizon-Spielbereich: * - linke Navigation: 128px * - nutzbarer linker Spielbereich: ca. 642px * - daraus ergibt sich als Standard-Anker: 128 + 642 = 770px * * Falls Horizon in einem Skin/Zoom minimal abweicht, sind nur diese * Werte anzupassen. Keine Pixelwerte mehr tief in der Funktion. */ const HASA_POSITION = { SPIEL_LINKS_X: 128, SPIEL_BREITE_X: 642, BOX_X_OFFSET: 0, BOX_OBEN_Y: 132, BOX_Y_OFFSET: 0, RAND_RECHTS_MIN: 12, // 0.8.2 Beta: // Optischer Standardanker: // oben = Oberkante der Buddies-Leiste // links = linke Kante des Nachrichten-/Chatfensters unten // Wenn einer der Anker nicht gefunden wird, greift der alte Fallback. BUDDIES_Y_FALLBACK: 68, CHAT_LINKS_FALLBACK: 286, ANKER_Y_OFFSET: 0, ANKER_X_OFFSET: 0, // 0.8.3 Beta: // Anker nur übernehmen, wenn sie plausibel zum großen Horizon-Layout passen. // So vermeiden wir, dass HASA an einer kleinen Mini-Box oben links kleben bleibt. BUDDIES_MIN_LINKS: 350, BUDDIES_MAX_OBEN: 160, CHAT_MIN_OBEN_ANTEIL: 0.45, CHAT_MIN_LINKS: 120, CHAT_MIN_BREITE: 500, // 0.9.6 Beta: Magnetisches Einrasten beim Verschieben. SNAP_ABSTAND: 18 }; const STORAGE_FAVORITEN = 'haa_favoriten_v05'; const STORAGE_SPALTEN = 'haa_spalten_v05'; const STORAGE_ZU = 'haa_zu_v05'; const STORAGE_BAUALARM = 'haa_baualarm_v055'; const STORAGE_BAUSTATUS = 'haa_baustatus_v060'; const STORAGE_ALARM_AUSGELOEST = 'haa_alarm_ausgeloest_v060'; const STORAGE_FENSTER_POSITION = 'haa_fenster_position_v090'; const STORAGE_ASCENSION_STATUS = 'haa_ascension_status_v091'; const STORAGE_ZIEL = 'haa_ziel_v095'; const STORAGE_VORLAUF = 'haa_vorlauf_v095'; const STORAGE_VORALARM_AUSGELOEST = 'haa_voralarm_ausgeloest_v095'; const STORAGE_BAUUEBERWACHUNG = 'haa_bauueberwachung_v099'; const STORAGE_FORSCHUNGSUEBERWACHUNG = 'haa_forschungsueberwachung_v101b'; const STORAGE_TECHTREE_GRAPH = 'haa_techtree_graph_v103i'; const STORAGE_TECHTREE_STATUS = 'haa_techtree_status_v103f'; const STORAGE_TECHTREE_DEBUG = 'haa_techtree_debug_v103g'; const STORAGE_UI_BOX_ZU = 'haa_ui_box_zu_v101e1'; const STORAGE_UI_AKTIVER_BEREICH = 'haa_ui_aktiver_bereich_v101e2'; const STORAGE_UI_SCROLL = 'haa_ui_scroll_v101e2'; const STORAGE_TECHTREE_SPIELSTAND = 'haa_techtree_spielstand_v102'; const STORAGE_TECHTREE_ERLEDIGT_ZEIGEN = 'haa_techtree_erledigt_zeigen_v102'; const STORAGE_STAN_STAMMDATEN = 'haa_stan_stammdaten_v103'; const STORAGE_STAN_VERSIONEN = 'haa_stan_versionen_v103a'; const STORAGE_STAN_STATUS = 'haa_stan_status_v103'; const STORAGE_STAN_AUSWAHL = 'haa_stan_auswahl_v103'; const STORAGE_STAN_NAVIGATION = 'haa_stan_navigation_v103'; const STORAGE_STAN_AUSWAHL_OFFEN = 'haa_stan_auswahl_offen_v103b'; const STORAGE_SEITENSCAN_STATUS = 'haa_seitenscan_status_v103'; const TECHTREE_BASIS_URL = 'https://techtree.kommandobasis.eu/objects/details/id/'; const STAN_START_DATENSTAND = '20260802'; const STAN_RHYTHMUS_TAGE = 7; const TECHTREE_CACHE_MS = 24 * 60 * 60 * 1000; const HASA_ALARM_VORLAUF_SEKUNDEN = 2; const HASA_SYNC_INTERVALL_MS = 60000; const STANDARD_FAVORITEN = [ 'Erhöht Gebäudeausbaugeschwindigkeit', 'Reduziert Gebäudepreise', 'Ascension-Vervielfältigung' ]; const STANDARD_SPALTEN = { stufe: true, letzte: true, kosten: true, invest: true }; function verdopplung(max) { const a = []; let k = 1; for (let i = 0; i < max; i++) { a.push(k); k *= 2; } return a; } function skill(kategorie, kosten, max) { return { kategorie, kosten, max }; } const K31 = verdopplung(31); const HASA_SKILLS = { // Produktion 'Erhöht Gebäudeausbaugeschwindigkeit': skill('Produktion', K31, 31), 'Erhöht Gebäudebauplätze': skill('Produktion', verdopplung(10), 10), 'Erhöht Gewinn von Bauplatz durch Infrastruktur': skill('Produktion', K31, 31), 'Erhöht die Größe des Mondbauplatzes': skill('Produktion', K31, 31), 'Erhöht Schiffsbaugeschwindigkeit': skill('Produktion', K31, 31), 'Erhöht Schiffsreparaturgeschwindigkeit': skill('Produktion', K31, 31), 'Erhöht Waffenbaugeschwindigkeit': skill('Produktion', K31, 31), // Rohstoffe 'Erhöhte Metallproduktion': skill('Rohstoffe', K31, 31), 'Erhöhte Mineralproduktion': skill('Rohstoffe', K31, 31), 'Erhöhte Treibstoffproduktion': skill('Rohstoffe', K31, 31), 'Vergrößerte Metalllager': skill('Rohstoffe', K31, 31), 'Vergrößerte Minerallager': skill('Rohstoffe', K31, 31), 'Vergrößerte Treibstofflager': skill('Rohstoffe', K31, 31), 'Erhöhte Bevölkerung': skill('Rohstoffe', K31, 31), 'Erhöhte Energieproduktion': skill('Rohstoffe', K31, 31), 'Reduziert Gebäudepreise': skill('Rohstoffe', K31, 31), 'Reduziert Forschungskosten': skill('Rohstoffe', K31, 31), // Flotte 'Senkt Treibstoffverbrauch der Schiffe': skill('Flotte', K31, 31), 'Erhöht die interplanetare Geschwindigkeit der Schiffe': skill('Flotte', K31, 31), 'Erhöht die Flottenkontrolle der Flaggschiffe': skill('Flotte', K31, 31), 'Erhöht Rohstoffabbau durch Bergbauschiffe': skill('Flotte', K31, 31), 'Erhöht den Laderaume der Schiffe': skill('Flotte', K31, 31), 'Reduziert den Bedarf an Hyperraumkontrollzyklen der Schiffe': skill('Flotte', K31, 31), 'Erhöht die Größe des Raumhafens': skill('Flotte', K31, 31), 'Erhöht die Größe der Raumstation': skill('Flotte', K31, 31), 'Erhöht die Größe des Flottenbunkers': skill('Flotte', K31, 31), // Gebäude 'Grundausstattung des Heimatplaneten': skill('Gebäude', [1, 2, 4], 3), 'Grundausstattung der Kolonien': skill('Gebäude', [1, 2], 2), // Imperium 'Imperium Coloniale Supremum': skill('Imperium', [10, 100, 1000], 3), 'Erhöhte Koloniekontrolle': skill('Imperium', [4, 16, 64, 256, 1024], 5), // 'Ascension-Vervielfältigung': skill('Imperium', [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192], 31), 'Ascension-Vervielfältigung': skill('Imperium', K31, 31), 'Panzerbrechende Maschinenkanonen': skill('Waffen', [1, 10, 100], 3), 'Aktivkühlung von Maschinenkanonen': skill('Waffen', [1, 10, 100], 3), 'Stickstoffkühlung von Protonenkanonen': skill('Waffen', [1, 10, 100], 3), 'Magnetische Plasmaverdichtung': skill('Waffen', [1, 10, 100], 3), 'Fokussierte Plasmafeldstabilisierung': skill('Waffen', [1, 10, 100], 3), 'Resonanzmodulation von Ionenstrahlen': skill('Waffen', [1, 10, 100], 3), 'Hochenergetische Ionisierung': skill('Waffen', [1, 10, 100], 3), // Perks 'Alle Gebäude fertig stellen': skill('Perks', [1], 1), 'Aktuelle Forschung fertig stellen': skill('Perks', [1], 1), 'Alle Schiffe fertig stellen': skill('Perks', [1], 1), 'Alle Verteidigungsanlagen fertig stellen': skill('Perks', [1], 1), 'Alle Expeditionen abschließen': skill('Perks', [1], 1), 'Alle Planetaren Hypersprünge abschließen': skill('Perks', [1], 1), 'Alle Flotten kehren sofort zurück': skill('Perks', [1], 1), 'Forschungssprung': skill('Perks', [25], 1), 'Aggregatsverbesserung': skill('Perks', [25], 1), // Ereignisse 'Reduziert Risiko von Alien Flotten': skill('Ereignisse', K31, 31), 'Erhöht die Häufigkeit von Motivationsvideos': skill('Ereignisse', K31, 31), // Forschung 'Grundausstattung an Forschungen': skill('Forschung', [10, 20, 40, 80, 160], 5) }; function text(el) { return el ? el.textContent.replace(/\s+/g, ' ').trim() : ''; } function zahl(s) { if (!s || s === 'N/A') return null; s = String(s).replace(',', '.').replace(/[^\d.]/g, ''); return s ? Number(s) : null; } function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function summeBis(kosten, stufe) { if (!kosten || stufe <= 0) return 0; let s = 0; for (let i = 0; i < stufe && i < kosten.length; i++) { s += kosten[i]; } return s; } function letzteAusDatenbank(name, stufe) { const db = HASA_SKILLS[name]; if (!db || stufe <= 0) return null; return db.kosten[stufe - 1] ?? null; } function investAusDatenbank(name, stufe) { const db = HASA_SKILLS[name]; if (!db) return null; return summeBis(db.kosten, stufe); } function leseAP() { for (const el of Array.from(document.querySelectorAll('p, div'))) { const m = text(el).match(/Du kannst\s+([\d.,]+)\s+Ascend-Punkte ausgeben/i); if (m) return zahl(m[1]); } return null; } function leseAPBeimNaechstenReset() { const seitenText = text(document.body); const muster = [ /Beim Zurücksetzen werden\s+([\d.,]+)\s+Punkte verfügbar/i, /Beim Zuruecksetzen werden\s+([\d.,]+)\s+Punkte verfuegbar/i, /(?:nächsten|naechsten)\s+(?:Reset|Zurücksetzen)[^\d]{0,80}([\d.,]+)\s+(?:Ascend-?)?Punkte/i ]; for (const regex of muster) { const m = seitenText.match(regex); if (m) return zahl(m[1]); } return null; } function leseAscensions() { const daten = []; const kats = Array.from(document.querySelectorAll('.AscAccCatHeadBox')); kats.forEach(catBox => { const kategorie = text(catBox.querySelector('.AscAccCatHead')); const content = catBox.nextElementSibling; if (!kategorie || !content) return; const rows = Array.from(content.querySelectorAll('tr.AscAccBldClsBody')); for (let i = 0; i < rows.length; i += 2) { const nameEl = rows[i].querySelector('.AscAccCategory'); const row2 = rows[i + 1]; if (!nameEl || !row2) continue; const werte = Array.from(row2.querySelectorAll('.AscAccFuncDescTextright')).map(text); const stufe = zahl(werte[0]); const kostenText = werte[1] || ''; const kosten = zahl(kostenText); let name = text(nameEl); if (stufe !== null) { name = name.replace(new RegExp('\\s+' + stufe + '$'), ''); } daten.push({ kategorie, name, stufe, kosten, kostenText, letzte: letzteAusDatenbank(name, stufe), invest: investAusDatenbank(name, stufe), meta: HASA_SKILLS[name] || null }); } }); return daten; } function ladeFavoriten(daten) { const gespeicherteFavoriten = laden(STORAGE_FAVORITEN, null); if (Array.isArray(gespeicherteFavoriten)) { return gespeicherteFavoriten; } return daten.filter(e => STANDARD_FAVORITEN.includes(e.name)).map(e => e.name); } function ladeSpalten() { const gespeicherteSpalten = laden(STORAGE_SPALTEN, null); if (gespeicherteSpalten && typeof gespeicherteSpalten === 'object' && !Array.isArray(gespeicherteSpalten)) { return Object.assign({}, STANDARD_SPALTEN, gespeicherteSpalten); } return Object.assign({}, STANDARD_SPALTEN); } function gmSpeicherVerfuegbar() { return typeof GM_getValue === 'function' && typeof GM_setValue === 'function'; } function hasaSpeicherName() { return gmSpeicherVerfuegbar() ? 'Tampermonkey GM' : 'localStorage'; } function baueHasaStatusHtml() { let html = ''; html += '
'; html += '
Version: ' + esc(HASA_VERSION) + '
'; html += '
Speicher: ' + esc(hasaSpeicherName()) + '
'; html += '
Build: ' + esc(HASA_BUILD_LABEL) + '
'; html += '
'; return html; } function ladeHasaZiel() { const ziel = laden(STORAGE_ZIEL, null); if (ziel && typeof ziel === 'object' && !Array.isArray(ziel)) { return Object.assign({ typ: 'aktueller-bau', name: 'Aktueller Bau' }, ziel); } return { typ: 'aktueller-bau', name: 'Aktueller Bau' }; } function speichereHasaZiel(typ) { speichern(STORAGE_ZIEL, { typ: typ || 'aktueller-bau', name: typ === 'cc' ? 'Koloniezentrum / Kolonialverwaltung' : 'Aktueller Bau', gespeichertAm: Date.now() }); } function ladeVorlaufSekunden() { const wert = Number(laden(STORAGE_VORLAUF, 60)); if (!Number.isFinite(wert) || wert < 0) { return 60; } return wert; } function speichereVorlaufSekunden(wert) { const sekunden = Number(wert); speichern(STORAGE_VORLAUF, Number.isFinite(sekunden) && sekunden >= 0 ? sekunden : 60); } function baueVorlaufOption(wert, label, aktuell) { return ''; } function baueZielUndVorlaufHtml() { const ziel = ladeHasaZiel(); const vorlauf = ladeVorlaufSekunden(); let html = ''; html += '
'; html += ''; html += ''; html += ''; html += ''; html += '
'; return html; } function verbindeZielUndVorlaufButtons(neuAufbauen) { const zielSelect = document.getElementById('haa-ziel-select'); if (zielSelect) { zielSelect.onchange = function () { speichereHasaZiel(zielSelect.value); if (typeof neuAufbauen === 'function') { neuAufbauen(); } }; } const vorlaufSelect = document.getElementById('haa-vorlauf-select'); if (vorlaufSelect) { vorlaufSelect.onchange = function () { speichereVorlaufSekunden(vorlaufSelect.value); speichern(STORAGE_VORALARM_AUSGELOEST, ''); HAA_VORALARM_AUSGELOEST_LAUF = ''; if (typeof neuAufbauen === 'function') { neuAufbauen(); } }; } } function speichern(key, wert) { try { if (gmSpeicherVerfuegbar()) { GM_setValue(key, wert); return; } } catch (e) { console.warn('HASA GM-Speicher fehlgeschlagen, nutze localStorage:', e); } localStorage.setItem(key, JSON.stringify(wert)); } function laden(key, fallback) { try { if (gmSpeicherVerfuegbar()) { const wert = GM_getValue(key, undefined); if (wert !== undefined && wert !== null && wert !== '') { return wert; } } } catch (e) { console.warn('HASA GM-Lesen fehlgeschlagen, nutze localStorage:', e); } try { const s = localStorage.getItem(key); if (s === null || s === undefined || s === '') { return fallback; } return JSON.parse(s); } catch (e) { return fallback; } } function istZu() { return laden(STORAGE_ZU, '0') === '1'; } function setZu(zu) { speichern(STORAGE_ZU, zu ? '1' : '0'); } function px(wert) { return Math.round(Number(wert)) + 'px'; } function findeHorizonSpielLayout() { const divs = Array.from(document.querySelectorAll('div')); return divs.find(function (el) { const style = el.getAttribute('style') || ''; return /left:\s*128px/i.test(style) && /top:\s*0px/i.test(style) && /right:\s*0px/i.test(style); }) || null; } function rechteckGueltig(r) { return r && Number.isFinite(r.left) && Number.isFinite(r.top) && Number.isFinite(r.width) && Number.isFinite(r.height) && r.width > 0 && r.height > 0; } function findeBuddiesAnker() { const elemente = Array.from(document.querySelectorAll('a, div, td, span, nobr, font')); const kandidaten = []; for (const el of elemente) { if (!/Buddies\s*:/i.test(text(el))) { continue; } let kandidat = el; for (let i = 0; i < 5 && kandidat; i++) { const r = kandidat.getBoundingClientRect(); if (rechteckGueltig(r) && r.width >= 120 && r.height >= 18 && r.left >= HASA_POSITION.BUDDIES_MIN_LINKS && r.top >= 0 && r.top <= HASA_POSITION.BUDDIES_MAX_OBEN) { kandidaten.push({ el: kandidat, rect: r }); } kandidat = kandidat.parentElement; } } if (!kandidaten.length) { return null; } kandidaten.sort(function (a, b) { // Oberste plausible Buddies-Leiste im rechten Kopfbereich. return a.rect.top - b.rect.top; }); return kandidaten[0].el; } function findeNachrichtenFensterAnker() { const elemente = Array.from(document.querySelectorAll('div, table, tr, td, input, textarea, button')); const kandidaten = []; const minTop = window.innerHeight * HASA_POSITION.CHAT_MIN_OBEN_ANTEIL; for (const el of elemente) { const t = text(el); if (!t) { continue; } const istChat = /An\s+Allianz\s+senden/i.test(t) || /\[\d{2}:\d{2}\]/.test(t); if (!istChat) { continue; } let kandidat = el; for (let i = 0; i < 7 && kandidat; i++) { const r = kandidat.getBoundingClientRect(); if (rechteckGueltig(r) && r.width >= HASA_POSITION.CHAT_MIN_BREITE && r.left >= HASA_POSITION.CHAT_MIN_LINKS && r.top >= minTop) { kandidaten.push({ el: kandidat, rect: r }); } kandidat = kandidat.parentElement; } } if (!kandidaten.length) { return null; } kandidaten.sort(function (a, b) { // Möglichst das große, untere Nachrichtenfenster nehmen, // aber keine Mini-Boxen aus dem Kopfbereich. if (Math.abs(b.rect.top - a.rect.top) > 8) { return b.rect.top - a.rect.top; } return b.rect.width - a.rect.width; }); return kandidaten[0].el; } function ermittleSpielLinks() { const layout = findeHorizonSpielLayout(); if (layout) { const r = layout.getBoundingClientRect(); if (Number.isFinite(r.left) && r.left > 0) { return r.left; } } return HASA_POSITION.SPIEL_LINKS_X; } function ermittleBoxLinks(box) { const chat = findeNachrichtenFensterAnker(); if (chat) { const r = chat.getBoundingClientRect(); if (rechteckGueltig(r)) { return r.left + HASA_POSITION.ANKER_X_OFFSET; } } // Fallback, wenn die Chat-/Nachrichtenbox im aktuellen Fenster nicht sichtbar ist. if (HASA_POSITION.CHAT_LINKS_FALLBACK !== null && HASA_POSITION.CHAT_LINKS_FALLBACK !== undefined) { return HASA_POSITION.CHAT_LINKS_FALLBACK + HASA_POSITION.ANKER_X_OFFSET; } const basisLinks = ermittleSpielLinks(); const links = basisLinks + HASA_POSITION.SPIEL_BREITE_X + HASA_POSITION.BOX_X_OFFSET; if (!box || !box.offsetWidth) { return links; } const maxLinks = window.innerWidth - box.offsetWidth - HASA_POSITION.RAND_RECHTS_MIN; if (Number.isFinite(maxLinks) && maxLinks > 0) { return Math.min(links, maxLinks); } return links; } function ermittleBoxOben() { const buddies = findeBuddiesAnker(); if (buddies) { const r = buddies.getBoundingClientRect(); if (rechteckGueltig(r)) { return r.top + HASA_POSITION.ANKER_Y_OFFSET; } } if (HASA_POSITION.BUDDIES_Y_FALLBACK !== null && HASA_POSITION.BUDDIES_Y_FALLBACK !== undefined) { return HASA_POSITION.BUDDIES_Y_FALLBACK + HASA_POSITION.ANKER_Y_OFFSET; } return HASA_POSITION.BOX_OBEN_Y + HASA_POSITION.BOX_Y_OFFSET; } function setzeBoxPosition(box, links, oben) { box.style.left = px(links); box.style.top = px(oben); } function ladeGespeicherteFensterPosition() { const pos = laden(STORAGE_FENSTER_POSITION, null); if (!pos) { return null; } const links = Number(pos.left); const oben = Number(pos.top); if (!Number.isFinite(links) || !Number.isFinite(oben)) { return null; } return { left: links, top: oben }; } function speichereFensterPosition(box) { if (!box) { return; } const r = box.getBoundingClientRect(); speichern(STORAGE_FENSTER_POSITION, { left: Math.round(r.left), top: Math.round(r.top), gespeichertAm: Date.now() }); } function begrenzeFensterPosition(box, links, oben) { const rand = 8; const breite = box && box.offsetWidth ? box.offsetWidth : 380; const hoehe = box && box.offsetHeight ? box.offsetHeight : 120; const maxLinks = Math.max(rand, window.innerWidth - breite - rand); const maxOben = Math.max(rand, window.innerHeight - hoehe - rand); return { left: Math.min(Math.max(rand, links), maxLinks), top: Math.min(Math.max(rand, oben), maxOben) }; } function snappeFensterPosition(box, links, oben, eventObj) { if (eventObj && eventObj.shiftKey) { return { left: links, top: oben }; } const abstand = HASA_POSITION.SNAP_ABSTAND || 18; const rand = 8; const breite = box && box.offsetWidth ? box.offsetWidth : 380; const hoehe = box && box.offsetHeight ? box.offsetHeight : 120; let neuLinks = links; let neuOben = oben; const rechterRand = window.innerWidth - breite - rand; const untererRand = window.innerHeight - hoehe - rand; if (Math.abs(neuLinks - rand) <= abstand) { neuLinks = rand; } if (Math.abs(neuLinks - rechterRand) <= abstand) { neuLinks = rechterRand; } if (Math.abs(neuOben - rand) <= abstand) { neuOben = rand; } if (Math.abs(neuOben - untererRand) <= abstand) { neuOben = untererRand; } const buddies = findeBuddiesAnker(); if (buddies) { const r = buddies.getBoundingClientRect(); if (rechteckGueltig(r) && Math.abs(neuOben - r.top) <= abstand) { neuOben = r.top; } } const chat = findeNachrichtenFensterAnker(); if (chat) { const r = chat.getBoundingClientRect(); if (rechteckGueltig(r)) { if (Math.abs(neuLinks - r.left) <= abstand) { neuLinks = r.left; } if (Math.abs(neuOben - r.top) <= abstand) { neuOben = r.top; } } } return { left: neuLinks, top: neuOben }; } function positioniereBox(box) { const gespeichert = ladeGespeicherteFensterPosition(); if (gespeichert) { const begrenzt = begrenzeFensterPosition(box, gespeichert.left, gespeichert.top); setzeBoxPosition(box, begrenzt.left, begrenzt.top); return; } setzeBoxPosition( box, ermittleBoxLinks(box), ermittleBoxOben() ); } function istDragSperrElement(el) { return !!(el && el.closest && el.closest('button, input, textarea, select, option, a, label')); } function starteHasaDrag(e) { if (e.button !== 0 || istDragSperrElement(e.target)) { return; } const box = document.getElementById('haa-box'); if (!box) { return; } const r = box.getBoundingClientRect(); HAA_DRAG_AKTIV = true; HAA_DRAG_BOX = box; HAA_DRAG_START_X = e.clientX; HAA_DRAG_START_Y = e.clientY; HAA_DRAG_BOX_X = r.left; HAA_DRAG_BOX_Y = r.top; box.style.cursor = 'grabbing'; box.style.userSelect = 'none'; e.preventDefault(); } function bewegeHasaDrag(e) { if (!HAA_DRAG_AKTIV || !HAA_DRAG_BOX) { return; } const links = HAA_DRAG_BOX_X + (e.clientX - HAA_DRAG_START_X); const oben = HAA_DRAG_BOX_Y + (e.clientY - HAA_DRAG_START_Y); const geschnappt = snappeFensterPosition(HAA_DRAG_BOX, links, oben, e); const begrenzt = begrenzeFensterPosition(HAA_DRAG_BOX, geschnappt.left, geschnappt.top); setzeBoxPosition(HAA_DRAG_BOX, begrenzt.left, begrenzt.top); e.preventDefault(); } function beendeHasaDrag() { if (!HAA_DRAG_AKTIV || !HAA_DRAG_BOX) { return; } HAA_DRAG_BOX.style.cursor = 'move'; HAA_DRAG_BOX.style.userSelect = ''; speichereFensterPosition(HAA_DRAG_BOX); HAA_DRAG_AKTIV = false; HAA_DRAG_BOX = null; } function aktiviereHasaDrag(box) { if (!box || box.dataset.hasaDrag === '1') { return; } box.dataset.hasaDrag = '1'; box.title = 'hasa-Fenster mit der Maus verschieben'; box.style.cursor = 'move'; box.addEventListener('mousedown', starteHasaDrag); } document.addEventListener('mousemove', bewegeHasaDrag); document.addEventListener('mouseup', beendeHasaDrag); function grundBox() { const alt = document.getElementById('haa-box'); if (alt) alt.remove(); const box = document.createElement('div'); box.id = 'haa-box'; box.style.position = 'fixed'; box.style.zIndex = '999999'; box.style.background = '#211d18'; box.style.color = '#ffffff'; box.style.border = '2px solid #b89b5e'; box.style.padding = '8px'; box.style.fontFamily = 'Arial, sans-serif'; box.style.fontSize = '13px'; // HASA 1.1: kleine Displays behalten die normale Schriftgröße. // Bei Platzmangel wird innerhalb von HASA horizontal gescrollt. box.style.minWidth = '0'; box.style.width = '760px'; box.style.maxWidth = 'calc(100vw - 24px)'; box.style.maxHeight = 'calc(100vh - 16px)'; box.style.overflowY = 'hidden'; box.style.overflowX = 'auto'; box.style.boxShadow = '0 0 8px #000'; box.style.borderRadius = '3px'; document.body.appendChild(box); const basisLinks = ermittleBoxLinks(null); const verfuegbar = Math.max(220, window.innerWidth - basisLinks - HASA_POSITION.RAND_RECHTS_MIN); box.style.width = Math.min(760, verfuegbar) + 'px'; box.style.maxWidth = verfuegbar + 'px'; positioniereBox(box); aktiviereHasaDrag(box); return box; } function baueBauzeitAnalyse(daten) { const speed = daten.find(e => e.name === 'Erhöht Gebäudeausbaugeschwindigkeit'); const preis = daten.find(e => e.name === 'Reduziert Gebäudepreise'); if (!speed || !preis) return ''; const stSpeed = speed.stufe || 0; const stPreis = preis.stufe || 0; const speedFaktor = Math.pow(1.30, stSpeed); const kostenFaktor = Math.pow(0.80, stPreis); const bauzeitFaktor = kostenFaktor / speedFaktor; const prozent = bauzeitFaktor * 100; const ersparnis = 100 - prozent; return '' + '
' + '
🏗 Bauzeit
' + '
Speed St. ' + esc(stSpeed) + ' → ×' + speedFaktor.toFixed(3) + '
' + '
Preis St. ' + esc(stPreis) + ' → ×' + kostenFaktor.toFixed(3) + '
' + '
' + prozent.toFixed(1) + '% der Grundbauzeit
' + '
verkürzt um ca. ' + ersparnis.toFixed(1) + '%
' + '
'; } function berechneInvestGesamt(daten) { let summe = 0; let unbekannt = 0; daten.forEach(e => { if (typeof e.invest === 'number') { summe += e.invest; } else if ((e.stufe || 0) > 0) { unbekannt++; } }); return { summe, unbekannt }; } function formatZahl(n) { if (n === null || n === undefined || Number.isNaN(n)) return '?'; return Number(n).toLocaleString('de-DE', { maximumFractionDigits: 2 }); } function erstelleAscensionStatus(daten, ap, apNaechsterReset) { const inv = berechneInvestGesamt(daten); return { apFrei: ap, apNaechsterReset: apNaechsterReset, apInvestiert: inv.summe, apGesamt: ap !== null ? inv.summe + ap : null, unbekannt: inv.unbekannt, skills: Array.isArray(daten) ? daten.map(function (e) { return { kategorie: e.kategorie, name: e.name, stufe: e.stufe, kosten: e.kosten, kostenText: e.kostenText, letzte: e.letzte, invest: e.invest }; }) : [], zuletztGesehen: Date.now() }; } function speichereAscensionStatus(status) { if (!status) { return; } speichern(STORAGE_ASCENSION_STATUS, status); } function ladeAscensionStatus() { return laden(STORAGE_ASCENSION_STATUS, null); } function baueAscensionDashboardHtml(status) { let html = ''; html += '
'; html += '
'; html += '
⭐ Ascension
'; html += ''; html += ' / '; html += '' + esc(investiert) + ''; html += ' / '; html += '' + esc(gesamt) + ''; html += '
'; if (status.apNaechsterReset !== null && status.apNaechsterReset !== undefined) { html += '
Beim nächsten Reset: ' + esc(formatZahl(status.apNaechsterReset)) + ' AP (Horizon-Wert)
'; } html += '
'; return html; } function baueNavigationHtml() { const aktuell = ermittleAktuellenBereich(); const eintraege = [ { key: 'building', label: 'Gebäude', icon: '🏢', url: 'https://horiversum.org/game/main/main.php?cmd=building' }, { key: 'research', label: 'Forschung', icon: '🔬', url: 'https://horiversum.org/game/main/main.php?cmd=research' }, { key: 'shipyard', label: 'Werft', icon: '🚀', url: 'https://horiversum.org/game/main/main.php?cmd=shipyard' }, { key: 'fleet', label: 'Flotte', icon: '🎯', url: 'https://horiversum.org/game/main/main.php?cmd=fleet' } ]; let html = ''; html += '
'; eintraege.forEach(function (e) { const aktiv = aktuell === e.key || (e.key === 'research' && aktuell === 'forschung') || (e.key === 'building' && aktuell === 'gebäude'); html += ''; html += '
' + esc(e.icon) + '
'; html += '
' + esc(e.label) + '
'; html += '
'; }); html += '
'; return html; } function baueBereichsanalyseHtml() { const bereich = ermittleAktuellenBereich(); const kandidaten = findeBauKandidaten(); const laufend = kandidaten.filter(function (k) { return k.info.status === 'laeuft'; }); const baubar = kandidaten.filter(function (k) { return k.info.status === 'baubar'; }); let titel = 'Aktueller Bereich'; if (bereich === 'building' || bereich === 'gebäude') titel = 'Gebäude'; else if (bereich === 'research' || bereich === 'forschung') titel = 'Forschung'; else if (bereich === 'shipyard') titel = 'Werft'; else if (bereich === 'fleet') titel = 'Flotte'; let html = ''; html += '
'; html += '
'; html += '
📊 Bereich
'; html += '
' + esc(titel) + '
'; html += '
'; if (bereich === 'building' || bereich === 'gebäude' || bereich === 'research' || bereich === 'forschung') { const laengste = laufend.reduce(function (max, k) { const sec = Number(k.info.sekunden); return Number.isFinite(sec) && sec > max ? sec : max; }, 0); html += '
'; html += '
' + esc(laufend.length) + '
'; html += '
laufend
'; html += '
' + esc(baubar.length) + '
'; html += '
baubar
'; html += '
' + esc(laengste > 0 ? formatSekunden(laengste) : '–') + '
'; html += '
längste Restzeit
'; html += '
'; } else { html += '
'; html += 'Kontextanzeige für diesen Bereich folgt später.'; html += '
'; } html += '
'; return html; } // ========================================================== // HASA 1.0.2b – Forschungsplanung mit Forschungslistenfix // ========================================================== function techTreeRequest(url) { return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: 'GET', url: url, timeout: 20000, headers: { 'Accept': 'text/html,application/xhtml+xml' }, onload: function (response) { if (response.status >= 200 && response.status < 300) { resolve(response.responseText || ''); } else { reject(new Error('HTTP ' + response.status)); } }, ontimeout: function () { reject(new Error('Zeitüberschreitung')); }, onerror: function () { reject(new Error('Netzwerkfehler')); } }); }); } function normalisiereTechTreeText(textWert) { return String(textWert || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim(); } function normalisiereTechTreeName(name) { return normalisiereTechTreeText(name) .toLowerCase() .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') .replace(/[^a-z0-9]+/g, ' ').trim(); } function techTreeTypLabel(typ) { return ({ building: 'Gebäude', research: 'Forschung', ship: 'Schiffe', defense: 'Verteidigung', unknown: 'Unbekannt' })[typ] || 'Unbekannt'; } function techTreeTypSymbol(typ) { return ({ building: '🏢', research: '🔬', ship: '🚀', defense: '🛡️', unknown: '•' })[typ] || '•'; } function ermittleTechTreeTypAusStan(id, name) { const daten = laden(STORAGE_STAN_STAMMDATEN, null); if (!daten || !Array.isArray(daten.objects)) return 'unknown'; const suchId = String(id || '').trim().toUpperCase(); const suchName = normalisiereTechTreeName(name); let treffer = null; if (suchId) { treffer = daten.objects.find(function (o) { return String(o && o.id || '').trim().toUpperCase() === suchId; }) || null; } if (!treffer && suchName) { const kandidaten = daten.objects.filter(function (o) { return normalisiereTechTreeName(o && o.id) === suchName || normalisiereTechTreeName(o && o.name) === suchName; }); if (kandidaten.length === 1) treffer = kandidaten[0]; } const typ = treffer && String(treffer.type || '').toLowerCase(); return ['building', 'research', 'ship', 'defense'].includes(typ) ? typ : 'unknown'; } function ermittleTechTreeTypAusIdUndName(id, name) { const kennung = String(id || '').toUpperCase(); const n = normalisiereTechTreeName(name); // Gebäude mit irreführenden technischen IDs zuerst fest zuordnen. // Insbesondere ist SHIPYARD eine Raumschiffswerft und kein Schiff. if (/^(SHIPYARD|RESEARCHCENTER|COMPUTERCENTER|CONSTRUCTIONFACILITY|HEADQUARTER|INFRASTRUCTURE|TRADECENTER|SPACEPORT|SPACESTATION|METALMINE|CRYSTALMINE|FUELREFINERY|METALSTORAGE|CRYSTALSTORAGE|FUELSTORAGE|SOLARCOLLECTOR|ROBOTFACTORY|HABITATION|FISSIONREACTOR|FUSIONREACTOR|TIDALPOWERPLANT|RECYCLINGFACILITY|RESCUESTATION|SPACEOBSERVATORY)$/.test(kennung)) return 'building'; if (/^(BLD|BUILD|STOR|SOL|HAB|ROB|HQ|LAB|YARD)/.test(kennung)) return 'building'; if (/^(DEF|TUR|SHIELD|BUNK)/.test(kennung)) return 'defense'; if (/^(RES|TECH|PHY|COMP|ENER|PSY|MAT|MINE|SOLT)/.test(kennung)) return 'research'; if (/^(SCOL|CCOL|BCOL|COL|FIGHT|TRANS|CARGO)/.test(kennung)) return 'ship'; // Verbindliche Spielregel: deutsche Namen mit der Endung „-technik“ sind Forschungen. // Diese Prüfung muss vor Gebäudebegriffen wie „Roboter“ oder „Solar“ stehen. if (/technik$/.test(n)) return 'research'; if (/forschung|physik|computer|energie|psychologie|material|minentechnik/.test(n)) return 'research'; if (/werft|mine|lager|kraftwerk|wohnbezirk|roboterfabrik|zentrum|verwaltung|bauhof|forschungseinrichtung|forschungslabor|raumhafen|raumstation|fabrik|hauptquartier|infrastruktur|sternwarte/.test(n)) return 'building'; if (/schiff|kolonieschiff|frachter|jaeger|kreuzer/.test(n)) return 'ship'; if (/verteidigung|geschuetz|schild|bunker/.test(n)) return 'defense'; return 'unknown'; } function ermittleTechTreeTypAusKontext(element) { const typen = [ { muster: /^(Gebäude|Gebaeude)$/i, typ: 'building' }, { muster: /^Forschungen?$/i, typ: 'research' }, { muster: /^(Raumschiffe?|Schiffe?)$/i, typ: 'ship' }, { muster: /^Verteidigung$/i, typ: 'defense' } ]; let knoten = element; for (let runde = 0; runde < 80 && knoten; runde++) { let vorher = knoten.previousElementSibling; while (vorher) { const vorherText = normalisiereTechTreeText(vorher.textContent); for (const eintrag of typen) { if (eintrag.muster.test(vorherText)) return eintrag.typ; } const treffer = typen.find(function (eintrag) { return Array.from(vorher.querySelectorAll('h1,h2,h3,h4,h5,th,strong,b')).some(function (h) { return eintrag.muster.test(normalisiereTechTreeText(h.textContent)); }); }); if (treffer) return treffer.typ; vorher = vorher.previousElementSibling; } knoten = knoten.parentElement; } return 'unknown'; } function ermittleStufeNebenLink(link) { const kandidaten = []; const zeile = link.closest('tr'); if (zeile) kandidaten.push(zeile.textContent); const parent = link.parentElement; if (parent) { kandidaten.push(parent.textContent); if (parent.nextElementSibling) kandidaten.push(parent.nextElementSibling.textContent); } if (link.nextSibling) kandidaten.push(link.nextSibling.textContent); for (const roh of kandidaten) { const t = normalisiereTechTreeText(roh); let m = t.match(/(?:Stufe|Level|Lvl\.?|Ausbaustufe)\s*:?\s*(\d+)/i); if (m) return Number(m[1]); m = t.match(/(?:>=|≥)\s*(\d+)/); if (m) return Number(m[1]); const zahlen = t.match(/\b\d+\b/g); if (zahlen && zahlen.length) return Number(zahlen[zahlen.length - 1]); } return null; } function ermittleTechTreeStufenmarkerFuerLink(link) { // Der Community-TechTree enthält zwei Richtungen: // „Stufe N“ = Voraussetzungen dieses Objekts (Vorwärtspfad) // „ab Stufe N“ = Reverse-Liste: wofür dieses Objekt später gebraucht wird // Für die Zielplanung darf ausschließlich „Stufe N“ verwendet werden. if (!link || !link.ownerDocument) return null; function markerAusElement(element) { if (!element || element === link || element.contains(link)) return null; const t = normalisiereTechTreeText(element.textContent); if (!t || t.length > 100) return null; let m = t.match(/^ab\s+Stufe\s*:?[ \t]*(\d+)\b/i); if (m) return { level: Number(m[1]), reverse: true, text: t }; m = t.match(/^Stufen?\s*:?[ \t]*(\d+)(?:\s*[-–—]\s*\d+)?\b/i); if (m) return { level: Number(m[1]), reverse: false, text: t }; return null; } const doc = link.ownerDocument; const elemente = Array.from(doc.querySelectorAll('h1,h2,h3,h4,h5,h6,legend,strong,b,th,div,span')); let letzter = null; for (const el of elemente) { const lage = el.compareDocumentPosition(link); if (!(lage & Node.DOCUMENT_POSITION_FOLLOWING)) continue; const marker = markerAusElement(el); if (marker) letzter = marker; } return letzter; } function istTechTreeReverseKontext(link) { let el = link; for (let i = 0; i < 10 && el; i++, el = el.parentElement) { const kennung = [el.id, el.className, el.getAttribute && el.getAttribute('data-tab'), el.getAttribute && el.getAttribute('aria-label')] .filter(Boolean).join(' ').toLowerCase(); if (/reverse|revers|rueckwaerts|rückwärts/.test(kennung)) return true; if (el.hidden || (el.getAttribute && el.getAttribute('aria-hidden') === 'true')) return true; } return false; } function parseTechTreeObjekt(html, erwarteteId) { const doc = new DOMParser().parseFromString(html, 'text/html'); const bodyText = normalisiereTechTreeText(doc.body ? doc.body.textContent : ''); const titelTreffer = bodyText.match(/Details\s*-\s*(.+?)\s*\(([A-Z0-9_-]+)\)\s*\[\s*Finale Stufe:\s*(\d+)\s*\]/i); const objektId = titelTreffer ? titelTreffer[2].toUpperCase() : String(erwarteteId || '').toUpperCase(); const name = titelTreffer ? normalisiereTechTreeText(titelTreffer[1]) : objektId; const finaleStufe = titelTreffer ? Number(titelTreffer[3]) : null; const requirements = []; const gesehen = new Set(); Array.from(doc.querySelectorAll('a[href*="/objects/"][href*="/id/"]')).forEach(function (link) { const href = link.getAttribute('href') || ''; const idTreffer = href.match(/\/objects\/(?:[^/?#]+\/)*?id\/([^/?#]+)/i); if (!idTreffer) return; const id = decodeURIComponent(idTreffer[1]).toUpperCase(); if (!id || id === objektId) return; if (istTechTreeReverseKontext(link)) return; const marker = ermittleTechTreeStufenmarkerFuerLink(link); // 1.1 poliert – allgemeiner Gebäude-Fix: // Der Community-TechTree beschriftet die BASIS-Voraussetzungen vieler // Gebäude nicht ausdrücklich mit „Stufe 1“. Zusätzliche Anforderungen // höherer Stufen tragen dagegen einen Stufenmarker (z. B. Stufe 19). // Markerlose echte Tabellenzeilen sind daher Grundvoraussetzungen für Stufe 1. if (marker && marker.reverse) return; const level = ermittleStufeNebenLink(link); if (!Number.isFinite(level)) return; let appliesToLevel = null; if (marker && Number.isFinite(marker.level)) { appliesToLevel = marker.level; } else { const zeile = link.closest('tr'); if (!zeile) return; appliesToLevel = 1; } const eindeutig = appliesToLevel + ':' + id + ':' + level; if (gesehen.has(eindeutig)) return; const reqName = normalisiereTechTreeText(link.textContent) || id; let typ = ermittleTechTreeTypAusStan(id, reqName); if (typ === 'unknown') typ = ermittleTechTreeTypAusIdUndName(id, reqName); if (typ === 'unknown') typ = ermittleTechTreeTypAusKontext(link); gesehen.add(eindeutig); requirements.push({ id: id, name: reqName, type: typ, level: level, appliesToLevel: appliesToLevel }); }); return { id: objektId, name: name, type: ermittleTechTreeTypAusIdUndName(objektId, name), finalLevel: finaleStufe, requirements: requirements, sourceUrl: TECHTREE_BASIS_URL + encodeURIComponent(String(objektId || '').toLowerCase()), fetchedAt: Date.now() }; } function ermittleTechTreeStufeAusKopf(kopf) { if (!kopf) return null; const body = findeKoerperZeileZumKopf(kopf); const container = kopf.closest('form, .BldTable, .BldContainerAccordionBox') || kopf.parentElement; const texte = [text(kopf), text(body), text(container)].filter(Boolean); const muster = [ /(?:Ausbaustufe|Forschungsstufe|Forschungslevel|Stufe|Level|Lvl\.?)\s*:?\s*(\d+)/i, /\(\s*(?:Ausbaustufe|Forschungsstufe|Forschungslevel|Stufe|Level|Lvl\.?)\s*:?\s*(\d+)\s*\)/i, /(?:aktuell|derzeit|erforscht)\s*:?\s*(\d+)/i ]; for (const roh of texte) { for (const regex of muster) { const treffer = roh.match(regex); if (treffer) return Number(treffer[1]); } } // Horizon zeigt die Stufe teilweise nur als erste reine Zahl in einer // Funktionszeile. Nur kleine plausible Stufen akzeptieren, niemals Kosten. const funktionsWerte = container ? Array.from(container.querySelectorAll('.BldFunctionRight, .BldFunction, td')).map(text) : []; for (const wert of funktionsWerte) { const m = wert.match(/^\s*(\d{1,3})\s*$/); if (m && Number(m[1]) <= 100) return Number(m[1]); } return null; } function leseTechTreeNameAusKopf(kopf) { if (!kopf) return ''; const fett = kopf.querySelector('b, strong'); let name = text(fett) || leseGebaeudeNameAusKopf(kopf) || text(kopf); name = String(name) .replace(/\s*[-–]\s*\d[\d'.]*\s*(?:Tonnen-?Typ|ts).*$/i, '') .replace(/\s*\([A-Z0-9_-]{2,8}\)\s*$/i, '') .replace(/\s*\((?:Ausbaustufe|Forschungsstufe|Stufe|Level)\s*:?\s*\d+\)\s*$/i, '') .trim(); return normalisiereGebaeudename(name); } function ermittleTechnischeIdAusKopf(kopf) { if (!kopf) return ''; const container = kopf.closest('form, .BldTable, .BldContainerAccordionBox, .ResContainerAccordionBox') || kopf.parentElement; const bild = container && container.querySelector('img[alt]'); const alt = bild ? String(bild.getAttribute('alt') || '').trim().toUpperCase() : ''; if (/^[A-Z0-9_-]{2,40}$/.test(alt)) return alt; const datenId = String((kopf.dataset && (kopf.dataset.id || kopf.dataset.object || kopf.dataset.key)) || '').trim().toUpperCase(); return /^[A-Z0-9_-]{2,40}$/.test(datenId) ? datenId : ''; } function speichereTechTreeSpielstandAusSeite() { const bereich = ermittleAktuellenBereich(); const typ = (bereich === 'research' || bereich === 'forschung') ? 'research' : ((bereich === 'building' || bereich === 'gebäude') ? 'building' : (bereich === 'shipyard' ? 'ship' : '')); if (!typ) return 0; const stand = laden(STORAGE_TECHTREE_SPIELSTAND, {}); const neu = stand && typeof stand === 'object' && !Array.isArray(stand) ? stand : {}; // 1.0.3l: Die aktuell geöffnete Horizon-Seite ist für ihren Bereich die Wahrheit. // Alte Werte dieses Typs werden vor dem Neu-Einlesen entfernt. Dadurch können // Forschungs- und Gebäudestände aus dem Leben vor einer Ascension nicht // weiter als "vorhanden" im Plan stehen bleiben. Object.keys(neu).forEach(function (key) { const eintrag = neu[key]; if (eintrag && eintrag.type === typ) delete neu[key]; }); let gefunden = 0; const koepfe = Array.from(document.querySelectorAll('.BldClsRow, .ResClsRow, [class*="ClsRow"]')); koepfe.forEach(function (kopf) { if (!kopf || kopf.closest('#haa-box')) return; const name = leseTechTreeNameAusKopf(kopf); let level = ermittleTechTreeStufeAusKopf(kopf); if (typ === 'ship' && !Number.isFinite(level)) level = 1; if (!name || name === 'Bauauftrag' || !Number.isFinite(level)) return; const technischeId = ermittleTechnischeIdAusKopf(kopf); const eintrag = { id: technischeId || '', name: name, level: level, type: typ, seenAt: Date.now() }; const schluessel = normalisiereTechTreeName(name); neu[schluessel] = eintrag; if (technischeId) neu['id:' + technischeId] = eintrag; const kurz = normalisiereTechTreeName(name .replace(/^(grundlagen der|grundlagen von|erweiterte|allgemeine)\s+/i, '') .replace(/\s+(forschung|technik)$/i, '')); if (kurz && kurz !== schluessel) neu[kurz] = eintrag; gefunden++; }); // 1.0.3j: Horizon-Forschungsseite zusätzlich direkt aus den sichtbaren // Forschungs-Kopfzeilen lesen. Das ist absichtlich unabhängig von den // CSS-Klassennamen, weil Horizon hier unterschiedliche Row-Klassen nutzt. // // Beispiel: // Physik 16 (physics - Forschungsstufe 16) // // Wichtig: Nur auf der echten Forschungsseite anwenden. Der Ausdruck // „Forschungsstufe“ verhindert außerdem, dass HASA-eigene Planzeilen // versehentlich als Spielstand eingelesen werden. if (typ === 'research') { const sichtText = document.body ? document.body.innerText : ''; const zeilen = sichtText.split(/\n+/) .map(function (z) { return z.replace(/\s+/g, ' ').trim(); }) .filter(Boolean); zeilen.forEach(function (zeile) { const m = zeile.match(/^(.{1,100}?)\s+(\d{1,3})\s*\(\s*([a-z0-9_-]{2,40})\s*-\s*Forschungsstufe\s+(\d{1,3})\s*\)/i); if (!m) return; const name = normalisiereGebaeudename(m[1]); const technischeId = String(m[3] || '').trim().toUpperCase(); const level = Number(m[4]); if (!name || !technischeId || !Number.isFinite(level)) return; const eintrag = { id: technischeId, name: name, level: level, type: 'research', seenAt: Date.now(), quelle: 'horizon-forschungs-kopfzeile' }; const schluessel = normalisiereTechTreeName(name); neu[schluessel] = eintrag; neu['id:' + technischeId] = eintrag; const kurz = normalisiereTechTreeName(name .replace(/^(grundlagen der|grundlagen von|erweiterte|allgemeine)\s+/i, '') .replace(/\s+(forschung|technik)$/i, '')); if (kurz && kurz !== schluessel) neu[kurz] = eintrag; gefunden++; }); } if (typ === 'building') { const sichtText = document.body ? document.body.innerText : ''; sichtText.split(/\n+/).map(z => z.replace(/\s+/g, ' ').trim()).filter(Boolean).forEach(function (zeile) { const m = zeile.match(/^(.{1,120}?)\s*\(\s*([a-z0-9_-]{2,50})\s*-\s*Ausbaustufe\s+(\d{1,3})\s*\)/i); if (!m) return; const name = normalisiereGebaeudename(m[1]); const technischeId = String(m[2] || '').trim().toUpperCase(); const level = Number(m[3]); if (!name || !technischeId || !Number.isFinite(level)) return; const eintrag = { id: technischeId, name, level, type: 'building', seenAt: Date.now(), quelle: 'horizon-gebaeude-kopfzeile' }; neu['id:' + technischeId] = eintrag; neu[normalisiereTechTreeName(name)] = eintrag; gefunden++; }); } if (gefunden) speichern(STORAGE_TECHTREE_SPIELSTAND, neu); return gefunden; } function ermittleErfuellungsstand(req) { const stand = laden(STORAGE_TECHTREE_SPIELSTAND, {}); const reqId = String(req && req.id || '').trim().toUpperCase(); const zielName = normalisiereTechTreeName(req.name); let eintrag = reqId && stand ? stand['id:' + reqId] : null; if (!eintrag) eintrag = stand && stand[zielName]; if (!eintrag && stand && typeof stand === 'object') { const zielKurz = zielName.replace(/^(grundlagen der|grundlagen von|erweiterte|allgemeine)\s+/, '') .replace(/\s+(forschung|technik)$/, ''); const kandidaten = Object.keys(stand).filter(function (key) { if (key.indexOf('id:') === 0 || !key || key.length < 4 || zielKurz.length < 4) return false; return key === zielKurz || key.includes(zielKurz) || zielKurz.includes(key); }); if (kandidaten.length === 1) eintrag = stand[kandidaten[0]]; } const aktuell = eintrag && Number.isFinite(Number(eintrag.level)) ? Number(eintrag.level) : null; return { aktuell: aktuell, erledigt: aktuell !== null && aktuell >= Number(req.level) }; } function normalisiereStanKategorie(roh) { const t = normalisiereTechTreeText(roh).toLowerCase(); if (/geb[aä]ude|building/.test(t)) return 'building'; if (/forschung|research|technik/.test(t)) return 'research'; if (/schiff|ship|flotte/.test(t)) return 'ship'; if (/verteidigung|defen[cs]e|abwehr/.test(t)) return 'defense'; return 'unknown'; } function ermittleStanDatenstandAusDateiname(name) { const m = String(name || '').match(/(20\d{6})/); return m ? m[1] : ''; } function parseStanXml(xmlText, dateiname) { const doc = new DOMParser().parseFromString(String(xmlText || ''), 'application/xml'); const parserFehler = doc.querySelector('parsererror'); if (parserFehler || !doc.documentElement || doc.documentElement.nodeName.toLowerCase() !== 'statistic') { throw new Error('Die Datei ist keine lesbare STAN-Statistik-XML.'); } const bereiche = [ { tag: 'buildings', type: 'building' }, { tag: 'researches', type: 'research' }, { tag: 'ships', type: 'ship' }, { tag: 'defense', type: 'defense' } ]; const objects = []; let order = 0; bereiche.forEach(function (bereich) { const wurzel = doc.querySelector(bereich.tag); if (!wurzel) return; Array.from(wurzel.querySelectorAll(':scope > category')).forEach(function (kat) { const category = String(kat.getAttribute('name') || '').trim(); Array.from(kat.querySelectorAll(':scope > object')).forEach(function (obj) { const id = String(obj.getAttribute('name') || '').trim().toUpperCase(); if (!id) return; const stufen = Array.from(obj.querySelectorAll('count[level]')).map(function (c) { return Number(c.getAttribute('level')); }).filter(Number.isFinite); objects.push({ id: id, name: id, type: bereich.type, category: category, maxLevel: stufen.length ? Math.max.apply(Math, stufen) : null, order: ++order }); }); }); }); if (!objects.length) throw new Error('Keine Gebäude, Forschungen, Schiffe oder Verteidigungen gefunden.'); return { objects: objects, dataDate: ermittleStanDatenstandAusDateiname(dateiname), fileName: String(dateiname || ''), importedAt: Date.now(), source: 'file' }; } function vergleicheStanVersionen(alt, neu) { const altMap = {}; const neuMap = {}; (alt && alt.objects || []).forEach(function (o) { altMap[o.type + ':' + o.id] = o; }); (neu && neu.objects || []).forEach(function (o) { neuMap[o.type + ':' + o.id] = o; }); const added = Object.keys(neuMap).filter(function (k) { return !altMap[k]; }).map(function (k) { return neuMap[k]; }); const missing = Object.keys(altMap).filter(function (k) { return !neuMap[k]; }).map(function (k) { return altMap[k]; }); const changed = Object.keys(neuMap).filter(function (k) { return altMap[k] && (String(altMap[k].category) !== String(neuMap[k].category) || Number(altMap[k].maxLevel) !== Number(neuMap[k].maxLevel)); }).map(function (k) { return { before: altMap[k], after: neuMap[k] }; }); return { added: added, missing: missing, changed: changed }; } function importiereStanDatei(datei) { if (!datei) return Promise.reject(new Error('Keine Datei ausgewählt.')); speichern(STORAGE_STAN_STATUS, { state: 'loading', message: 'STAN-Datei wird geprüft …', at: Date.now() }); baueBox(); return datei.text().then(function (inhalt) { const daten = parseStanXml(inhalt, datei.name); const alt = laden(STORAGE_STAN_STAMMDATEN, null); const delta = vergleicheStanVersionen(alt, daten); const versionen = laden(STORAGE_STAN_VERSIONEN, []); const liste = Array.isArray(versionen) ? versionen : []; liste.push({ dataDate: daten.dataDate, fileName: daten.fileName, importedAt: daten.importedAt, objects: daten.objects, delta: delta }); speichern(STORAGE_STAN_VERSIONEN, liste.slice(-12)); speichern(STORAGE_STAN_STAMMDATEN, daten); speichern(STORAGE_STAN_STATUS, { state: 'ok', message: daten.objects.length + ' Objekte gespeichert · +' + delta.added.length + ' neu · ' + delta.changed.length + ' geändert · ' + delta.missing.length + ' fehlen im neuen Stand (nicht gelöscht).', at: Date.now(), dataDate: daten.dataDate, fileName: daten.fileName }); return daten; }).catch(function (e) { speichern(STORAGE_STAN_STATUS, { state: 'error', message: 'STAN-Datei nicht importiert: ' + String(e && e.message || e), at: Date.now() }); throw e; }).finally(baueBox); } function stanAktualitaet(daten) { if (!daten || !daten.dataDate || !/^20\d{6}$/.test(daten.dataDate)) return { state: 'red', text: 'kein gültiger STAN-Datenstand' }; const y = Number(daten.dataDate.slice(0,4)); const m = Number(daten.dataDate.slice(4,6)) - 1; const d = Number(daten.dataDate.slice(6,8)); const stand = new Date(y,m,d,12,0,0,0); const naechster = new Date(stand.getTime() + STAN_RHYTHMUS_TAGE * 86400000); const jetzt = new Date(); if (jetzt < naechster) return { state: 'green', text: 'aktuell bis ' + naechster.toLocaleDateString('de-DE') }; const tage = Math.floor((jetzt - naechster) / 86400000); if (tage <= 1) return { state: 'yellow', text: 'neuer Wochenstand könnte verfügbar sein' }; return { state: 'red', text: 'Aktualisierung seit ' + naechster.toLocaleDateString('de-DE') + ' überfällig' }; } function ladeStanAuswahl() { const a = laden(STORAGE_STAN_AUSWAHL, null); return a && typeof a === 'object' ? a : null; } function techTreeDebugZeile(debug, text) { if (!Array.isArray(debug)) return; debug.push(String(text)); if (debug.length > 1200) debug.shift(); } function waehleVoraussetzungenBisStufe(objekt, zielStufe, debug, tiefe) { const grenze = Math.max(1, Number(zielStufe) || 1); const proId = {}; const einzug = ' '.repeat(Math.max(0, Number(tiefe) || 0)); const objektId = objekt && objekt.id || '?'; const objektName = objekt && objekt.name || objektId; const roh = (objekt && objekt.requirements || []); techTreeDebugZeile(debug, einzug + 'AUSWAHL ' + objektName + ' [' + objektId + '] bis Stufe ' + grenze + ' · Rohdaten: ' + roh.length); roh.forEach(function (req, index) { const giltAb = Math.max(1, Number(req.appliesToLevel) || 1); const key = req.id || normalisiereTechTreeName(req.name); const beschreibung = (req.name || req.id || '?') + ' [' + (req.id || '?') + '] benötigt ' + Number(req.level || 0) + ' · gilt ab Objektstufe ' + giltAb; if (giltAb > grenze) { techTreeDebugZeile(debug, einzug + ' − VERWORFEN #' + (index + 1) + ': ' + beschreibung + ' (liegt oberhalb der Zielstufe ' + grenze + ')'); return; } const alt = proId[key]; if (!alt) { proId[key] = Object.assign({}, req, { appliesToLevel: giltAb }); techTreeDebugZeile(debug, einzug + ' + ÜBERNOMMEN #' + (index + 1) + ': ' + beschreibung + ' (erste passende Forderung)'); return; } if (Number(req.level) > Number(alt.level)) { techTreeDebugZeile(debug, einzug + ' ~ ERSETZT #' + (index + 1) + ': ' + beschreibung + ' (höher als zuvor benötigt ' + Number(alt.level || 0) + ', bisher galt ab ' + Number(alt.appliesToLevel || 1) + ')'); proId[key] = Object.assign({}, req, { appliesToLevel: giltAb }); } else { techTreeDebugZeile(debug, einzug + ' = BEHALTEN #' + (index + 1) + ': ' + beschreibung + ' (vorhandene Forderung benötigt bereits ' + Number(alt.level || 0) + ')'); } }); const ausgewaehlt = Object.values(proId); techTreeDebugZeile(debug, einzug + 'ERGEBNIS AUSWAHL ' + objektName + ': ' + ausgewaehlt.length + ' Voraussetzung(en)'); return ausgewaehlt; } function sammleTechTreeAnforderungen(graph, zielId, zielStufe) { const debug = []; const objekt = graph && graph[zielId]; const grenze = Math.max(1, Number(zielStufe) || 1); techTreeDebugZeile(debug, '=== DIREKTE MINDESTANFORDERUNGEN ==='); techTreeDebugZeile(debug, 'Ziel: ' + String(zielId || '?') + ' · Zielstufe: ' + grenze); if (!objekt) { techTreeDebugZeile(debug, '! Zielobjekt ist noch nicht geladen.'); speichern(STORAGE_TECHTREE_DEBUG, { at: Date.now(), zielId: zielId, zielStufe: grenze, lines: debug }); return []; } // Teststufe 1: // KEINE Rekursion. KEINE Reverse-Auswertung. KEINE Unterzweige. // Wir lesen ausschließlich die direkten Mindestanforderungen des Zielobjekts. const direkt = waehleVoraussetzungenBisStufe(objekt, grenze, debug, 0); direkt.forEach(function (req) { const rid = String(req && req.id || '').toUpperCase(); const stanTyp = ermittleTechTreeTypAusStan(rid, req && req.name); if (stanTyp !== 'unknown') { req.type = stanTyp; req.typ = stanTyp; } else if (HASA_SICHERE_FORSCHUNGS_IDS.has(rid)) { req.type = 'research'; req.typ = 'research'; } else if (!req.type || req.type === 'unknown') { req.type = ermittleTechTreeTypAusIdUndName(rid, req && req.name); req.typ = req.type; } }); techTreeDebugZeile(debug, 'Direkte Anforderungen: ' + direkt.length); direkt.forEach(function (req, index) { const stand = ermittleErfuellungsstand(req); techTreeDebugZeile( debug, String(index + 1).padStart(2, '0') + '. ' + (req.name || req.id) + ' [' + (req.id || '?') + '] · benötigt ' + Number(req.level || 0) + ' · vorhanden ' + (stand.aktuell === null ? '?' : stand.aktuell) + (stand.erledigt ? ' · ERLEDIGT → aus reduzierter Ansicht raus' : ' · OFFEN → Merkliste') ); }); techTreeDebugZeile(debug, '=== ENDE DIREKTE MINDESTANFORDERUNGEN ==='); speichern(STORAGE_TECHTREE_DEBUG, { at: Date.now(), zielId: zielId, zielStufe: grenze, lines: debug }); console.groupCollapsed('[HASA Planer-Neustart] ' + String(zielId) + ' Stufe ' + grenze); debug.forEach(function (line) { console.log(line); }); console.groupEnd(); return direkt; } async function importiereTechTreeZiel(zielId, zielStufe, erzwingen) { const id = String(zielId || 'CCOL').trim().toUpperCase(); const grenze = Math.max(1, Number(zielStufe) || 1); let graph = laden(STORAGE_TECHTREE_GRAPH, {}); graph = graph && typeof graph === 'object' && !Array.isArray(graph) ? graph : {}; speichern(STORAGE_TECHTREE_STATUS, { state: 'loading', message: id + ' – direkte Mindestanforderungen werden gelesen …', id: id, at: Date.now() }); baueBox(); try { // Planer-Neustart Teststufe 1: // Nur die ausgewählte Zielseite lesen. Keine Unterobjekte nachladen. // Der Community-TechTree führt seine Objekt-IDs in den URLs klein geschrieben // (z. B. /id/weaponfactory, /id/darkenergytransmitter). Intern arbeitet // HASA weiterhin mit den stabilen Großbuchstaben-IDs. const techTreeUrlId = id.toLowerCase(); const html = await techTreeRequest(TECHTREE_BASIS_URL + encodeURIComponent(techTreeUrlId)); const objekt = parseTechTreeObjekt(html, id); graph[id] = objekt; speichern(STORAGE_TECHTREE_GRAPH, graph); const anforderungen = sammleTechTreeAnforderungen(graph, id, grenze); if (!anforderungen.length) { const roh = objekt && Array.isArray(objekt.requirements) ? objekt.requirements : []; if (roh.length) { speichern(STORAGE_TECHTREE_STATUS, { state: 'ok', message: 'Für Zielstufe ' + grenze + ' keine zusätzlichen Mindestanforderungen.', id: id, at: Date.now() }); return objekt; } throw new Error('Keine Mindestanforderungen aus dem TechTree gelesen – TechTree-Seite prüfen.'); } const offen = anforderungen.filter(function (req) { return !ermittleErfuellungsstand(req).erledigt; }); speichern(STORAGE_TECHTREE_STATUS, { state: 'ok', message: anforderungen.length + ' direkte Mindestanforderungen · ' + offen.length + ' davon offen · ', id: id, at: Date.now() }); return objekt; } catch (fehler) { speichern(STORAGE_TECHTREE_STATUS, { state: 'error', message: String(fehler && fehler.message ? fehler.message : fehler), id: id, at: Date.now() }); throw fehler; } finally { baueBox(); } } function aktivierePlanStatusSticky() { const box = document.getElementById('haa-box') || document.querySelector('[id^="haa"]'); if (!box) return; const checkboxen = Array.from(box.querySelectorAll('input[type="checkbox"]')); const checkbox = checkboxen.find(cb => ((cb.closest('label') || cb.parentElement)?.textContent || '').includes('vollständige Ansicht')); if (!checkbox) return; const label = checkbox.closest('label') || checkbox.parentElement; const host = label && label.parentElement; if (!host || host.dataset.hasaStickyPlanstatus === '1') return; host.dataset.hasaStickyPlanstatus = '1'; host.style.position = 'sticky'; host.style.top = '0'; host.style.zIndex = '20'; host.style.background = '#18140f'; } function aktivierePlanlistenKlappung() { const box = document.getElementById('haa-box') || document.querySelector('[id^="haa"]'); if (!box) return; const all = Array.from(box.querySelectorAll('div')); const liste = all.find(function (el) { if (el.dataset && el.dataset.hasaPlanListe === '1') return false; const t = (el.textContent || ''); const own = Array.from(el.children || []).map(function(c){ return c.textContent || ''; }).join(' '); return t.indexOf('benötigt') >= 0 && (t.indexOf('Hyperraumtechnik') >= 0 || t.indexOf('Flottentechnik') >= 0) && el.querySelectorAll('div').length >= 2 && el.querySelectorAll('button').length <= 2; }); if (!liste || !liste.parentNode) return; if (liste.previousElementSibling && liste.previousElementSibling.classList && liste.previousElementSibling.classList.contains('haa-plan-klapp-btn')) return; liste.dataset.hasaPlanListe = '1'; const btn = document.createElement('button'); btn.className = 'haa-plan-klapp-btn'; btn.type = 'button'; btn.style.cssText = 'font-size:11px;margin:4px 0 6px;padding:2px 7px;cursor:pointer;'; const key = 'haa_planliste_eingeklappt'; function setState(zu) { liste.style.display = zu ? 'none' : ''; btn.textContent = zu ? '▸ Planliste anzeigen' : '▾ Planliste einklappen'; speichern(key, !!zu); } btn.addEventListener('click', function(){ setState(liste.style.display !== 'none'); }); liste.parentNode.insertBefore(btn, liste); setState(!!laden(key, false)); } function baueStanNavigationHtml() { const daten = laden(STORAGE_STAN_STAMMDATEN, null); const status = laden(STORAGE_STAN_STATUS, null); const auswahl = ladeStanAuswahl(); const auswahlOffen = !auswahl || laden(STORAGE_STAN_AUSWAHL_OFFEN, '0') === '1'; let html = '
'; html += '
'; html += 'Import & Datenbestand'; html += '
'; const aktuell = stanAktualitaet(daten); const aktuellFarbe = aktuell.state === 'green' ? '#7CFF7C' : aktuell.state === 'yellow' ? '#FFD27C' : '#FF8080'; html += '
● ' + esc(aktuell.text) + (daten && daten.dataDate ? ' · Stand ' + esc(daten.dataDate) : '') + '
'; if (status) html += '
' + esc(status.message || '') + '
'; if (!daten || !Array.isArray(daten.objects)) return html + '
'; html += '
'; if (auswahl) { html += '
'; html += '
Gewähltes Ziel
' + esc(auswahl.name) + ' (' + esc(auswahl.id) + ')
'; html += ''; html += '
'; } else { html += '
Ziel auswählen
'; } if (auswahlOffen) { const typen = ['building', 'research', 'ship', 'defense']; html += '
'; typen.forEach(function (typ) { const gruppe = daten.objects.filter(function (o) { return o.type === typ && !istStanExplorerKategorieAusgeblendet(o.category); }).sort(function (a,b) { return Number(a.order)-Number(b.order) || a.name.localeCompare(b.name,'de'); }); if (!gruppe.length) return; const kategorien = []; const nachKategorie = {}; gruppe.forEach(function (o) { const kat = o.category || 'Ohne Untergruppe'; if (!nachKategorie[kat]) { nachKategorie[kat] = []; kategorien.push(kat); } nachKategorie[kat].push(o); }); html += '
'; html += '' + techTreeTypSymbol(typ) + ' ' + esc(techTreeTypLabel(typ)) + ' (' + gruppe.length + ')'; html += '
'; kategorien.forEach(function (kat) { const objekte = nachKategorie[kat]; html += '
'; html += '' + esc(kat) + ' (' + objekte.length + ')'; html += '
'; objekte.forEach(function (o) { const aktiv = auswahl && auswahl.id === o.id && auswahl.type === o.type; html += ''; }); html += '
'; }); html += '
'; }); html += '
'; } html += '
'; return html; } function baueTechTreeTestHtml() { const graph = laden(STORAGE_TECHTREE_GRAPH, {}); const auswahl = ladeStanAuswahl(); const zielId = auswahl && auswahl.id; const zielStufe = Number(auswahl && auswahl.level) || 1; const objekt = zielId && graph ? graph[zielId] : null; const status = laden(STORAGE_TECHTREE_STATUS, null); const erledigteZeigen = laden(STORAGE_TECHTREE_ERLEDIGT_ZEIGEN, '0') === '1'; const alle = objekt ? sammleTechTreeAnforderungen(graph, zielId, zielStufe) : []; const bewertet = alle.map(function (req) { return Object.assign({}, req, ermittleErfuellungsstand(req)); }); const offen = bewertet.filter(function (req) { return !req.erledigt; }); const erledigt = bewertet.filter(function (req) { return req.erledigt; }); const sichtbar = erledigteZeigen ? bewertet : offen; let html = baueStanNavigationHtml(); if (!zielId) return html + '
Nach dem STAN-Import ein Ziel auswählen.
'; html += '
'; html += '
'; if (status) html += '
' + esc(status.message || '') + '
'; if (objekt) { html += ''; html += '
offen: ' + offen.length + ' · erledigt: ' + erledigt.length + '
'; ['building','research','ship','defense','unknown'].forEach(function (typ) { const gruppe = sichtbar.filter(function (req) { return (req.type || 'unknown') === typ; }); if (!gruppe.length) return; html += '
' + techTreeTypSymbol(typ) + ' ' + esc(techTreeTypLabel(typ)) + '
'; gruppe.sort(function (a,b) { return String(a.name).localeCompare(String(b.name),'de'); }).forEach(function (req) { html += '
' + (req.erledigt ? '✓' : '➜') + 'benötigt ' + esc(req.level) + (req.aktuell !== null ? '
vorhanden ' + esc(req.aktuell) + '' : '') + '
'; }); }); } const debugDaten = laden(STORAGE_TECHTREE_DEBUG, null); if (debugDaten && Array.isArray(debugDaten.lines)) { html += '
'; html += 'Diagnose'; html += '
'; html += '
'; html += '
' + esc(debugDaten.lines.join('\n')) + '
'; html += '
'; } return html + '
'; } function verbindeTechTreeTestButtons() { const stanBtn = document.getElementById('haa-stan-import'); const stanDatei = document.getElementById('haa-stan-datei'); if (stanBtn && stanDatei) stanBtn.onclick = function () { stanDatei.value = ''; stanDatei.click(); }; if (stanDatei) stanDatei.onchange = function () { const datei = stanDatei.files && stanDatei.files[0]; if (datei) importiereStanDatei(datei).catch(function () {}); }; const auswahlUmschalten = document.getElementById('haa-stan-auswahl-umschalten'); if (auswahlUmschalten) auswahlUmschalten.onclick = function () { const offen = laden(STORAGE_STAN_AUSWAHL_OFFEN, '0') === '1'; speichern(STORAGE_STAN_AUSWAHL_OFFEN, offen ? '0' : '1'); baueBox(); }; Array.from(document.querySelectorAll('.haa-stan-ziel')).forEach(function (btn) { btn.onclick = function () { const auswahl = { id: btn.dataset.id, name: btn.dataset.name, type: btn.dataset.type, level: 1, selectedAt: Date.now() }; speichern(STORAGE_STAN_AUSWAHL, auswahl); speichern(STORAGE_STAN_NAVIGATION, { category: auswahl.type, targetId: auswahl.id, at: Date.now() }); speichern(STORAGE_STAN_AUSWAHL_OFFEN, '0'); baueBox(); }; }); const btn = document.getElementById('haa-techtree-import'); if (btn) btn.onclick = function () { const auswahl = ladeStanAuswahl(); if (!auswahl) return; btn.disabled = true; importiereTechTreeZiel(auswahl.id, auswahl.level || 1, true).catch(function () {}); }; const erledigt = document.getElementById('haa-techtree-erledigt'); if (erledigt) erledigt.onchange = function () { speichern(STORAGE_TECHTREE_ERLEDIGT_ZEIGEN, erledigt.checked ? '1' : '0'); baueBox(); }; Array.from(document.querySelectorAll('.haa-req-ziel')).forEach(function (zielBtn) { zielBtn.onclick = function () { const id = String(zielBtn.dataset.id || '').trim().toUpperCase(); const name = String(zielBtn.dataset.name || id).trim(); const type = String(zielBtn.dataset.type || 'unknown'); const level = Math.max(1, Number(zielBtn.dataset.level) || 1); if (!id) return; const neu = { id: id, name: name, type: type, level: level, selectedAt: Date.now(), quelle: 'anforderungsliste' }; speichern(STORAGE_STAN_AUSWAHL, neu); speichern(STORAGE_STAN_NAVIGATION, { category: type, targetId: id, at: Date.now() }); speichern(STORAGE_STAN_AUSWAHL_OFFEN, '0'); importiereTechTreeZiel(id, level, true).catch(function () {}); baueBox(); }; }); const debugKopieren = document.getElementById('haa-techtree-debug-kopieren'); if (debugKopieren) debugKopieren.onclick = function () { const el = document.getElementById('haa-techtree-debug-text'); const inhalt = el ? el.textContent : ''; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(inhalt).then(function () { debugKopieren.textContent = 'kopiert'; setTimeout(function () { debugKopieren.textContent = 'Debug kopieren'; }, 1200); }).catch(function () {}); } }; const debugLoeschen = document.getElementById('haa-techtree-debug-loeschen'); if (debugLoeschen) debugLoeschen.onclick = function () { speichern(STORAGE_TECHTREE_DEBUG, null); baueBox(); }; } function baueAccordionBereichHtml(id, titel, inhaltHtml) { const offen = HAA_UI_AKTIVER_BEREICH === id; let html = ''; html += '
'; html += '
'; html += '
'; return html; } function installierePlanScrollHinweise(inhalt) { if (!inhalt || inhalt.dataset.hasaScrollHinweise === '1') return; inhalt.dataset.hasaScrollHinweise = '1'; inhalt.style.position = 'relative'; const host = document.createElement('div'); host.className = 'haa-scroll-hinweise'; host.style.cssText = 'position:sticky;top:2px;right:2px;z-index:50;pointer-events:none;height:0;overflow:visible;'; host.innerHTML = '' + '' + '' + '' + ''; inhalt.prepend(host); const buttons = {}; Array.from(host.querySelectorAll('button')).forEach(function (b) { buttons[b.dataset.r] = b; b.style.cssText = 'pointer-events:auto;display:none;margin:0 2px;padding:2px 7px;border:2px solid #ff3030;border-radius:4px;background:#650000;color:#fff;font-weight:bold;font-size:18px;line-height:1;cursor:pointer;box-shadow:0 0 5px #000;'; }); function update() { const eps = 3; const oben = inhalt.scrollTop > eps; const unten = inhalt.scrollTop + inhalt.clientHeight < inhalt.scrollHeight - eps; const links = inhalt.scrollLeft > eps; const rechts = inhalt.scrollLeft + inhalt.clientWidth < inhalt.scrollWidth - eps; buttons.up.style.display = oben ? 'inline-block' : 'none'; buttons.down.style.display = unten ? 'inline-block' : 'none'; buttons.left.style.display = links ? 'inline-block' : 'none'; buttons.right.style.display = rechts ? 'inline-block' : 'none'; } buttons.up.onclick = function () { inhalt.scrollBy({ top: -Math.max(120, inhalt.clientHeight * 0.6), behavior: 'smooth' }); }; buttons.down.onclick = function () { inhalt.scrollBy({ top: Math.max(120, inhalt.clientHeight * 0.6), behavior: 'smooth' }); }; buttons.left.onclick = function () { inhalt.scrollBy({ left: -Math.max(160, inhalt.clientWidth * 0.6), behavior: 'smooth' }); }; buttons.right.onclick = function () { inhalt.scrollBy({ left: Math.max(160, inhalt.clientWidth * 0.6), behavior: 'smooth' }); }; inhalt.addEventListener('scroll', update, { passive: true }); window.addEventListener('resize', update, { passive: true }); setTimeout(update, 0); setTimeout(update, 250); } function verbindeHasaUiButtons(neuAufbauen) { const klappButton = document.getElementById('haa-box-klappen'); if (klappButton) { klappButton.onclick = function () { HAA_UI_BOX_ZU = !HAA_UI_BOX_ZU; speichern(STORAGE_UI_BOX_ZU, HAA_UI_BOX_ZU ? '1' : '0'); neuAufbauen(); }; } Array.from(document.querySelectorAll('#haa-box details.haa-accordion')).forEach(function (details) { details.addEventListener('toggle', function () { const bereich = details.dataset.haaBereich || ''; if (!details.open) { if (HAA_UI_AKTIVER_BEREICH === bereich) { HAA_UI_AKTIVER_BEREICH = ''; speichern(STORAGE_UI_AKTIVER_BEREICH, ''); } return; } HAA_UI_AKTIVER_BEREICH = bereich; speichern(STORAGE_UI_AKTIVER_BEREICH, bereich); Array.from(document.querySelectorAll('#haa-box details.haa-accordion')).forEach(function (anderes) { if (anderes !== details) anderes.open = false; }); }); }); const scrollStaende = laden(STORAGE_UI_SCROLL, {}); Array.from(document.querySelectorAll('#haa-box .haa-bereich-inhalt')).forEach(function (inhalt) { const bereich = inhalt.dataset.haaScrollBereich || ''; const gespeichert = scrollStaende && Number(scrollStaende[bereich]); if (Number.isFinite(gespeichert) && gespeichert > 0) { inhalt.scrollTop = gespeichert; } if (bereich === 'techtree') installierePlanScrollHinweise(inhalt); inhalt.addEventListener('scroll', function () { const aktuell = laden(STORAGE_UI_SCROLL, {}); const neu = aktuell && typeof aktuell === 'object' && !Array.isArray(aktuell) ? aktuell : {}; neu[bereich] = Math.round(inhalt.scrollTop); speichern(STORAGE_UI_SCROLL, neu); }, { passive: true }); }); } function baueBox() { speichereTechTreeSpielstandAusSeite(); const datenLive = leseAscensions(); const ap = leseAP(); const apNaechsterReset = leseAPBeimNaechstenReset(); const ascensionSichtbar = datenLive.length > 0; const ascStatusAktuell = ascensionSichtbar ? erstelleAscensionStatus(datenLive, ap, apNaechsterReset) : null; if (ascStatusAktuell) speichereAscensionStatus(ascStatusAktuell); const ascStatus = ascStatusAktuell || ladeAscensionStatus(); const datenAnzeige = ascensionSichtbar ? datenLive : (ascStatus && Array.isArray(ascStatus.skills) ? ascStatus.skills : []); const favoriten = ladeFavoriten(datenAnzeige); const spalten = ladeSpalten(); const ziel = aktualisiereUeberwachungsStatus(); const forschung = aktualisiereForschungsStatus(); const box = grundBox(); let html = ''; html += '
'; html += '
'; if (!HAA_UI_BOX_ZU) { let ascensionHtml = baueAscensionDashboardHtml(ascStatus); if (datenAnzeige.length) { ascensionHtml += ''; ascensionHtml += ''; if (spalten.stufe) ascensionHtml += ''; if (spalten.letzte) ascensionHtml += ''; if (spalten.kosten) ascensionHtml += ''; if (spalten.invest) ascensionHtml += ''; ascensionHtml += ''; datenAnzeige.filter(function (e) { return favoriten.includes(e.name); }).forEach(function (e) { const apFrei = ascStatus ? ascStatus.apFrei : null; const kaufbar = apFrei !== null && e.kosten !== null && apFrei >= e.kosten; const farbe = kaufbar ? '#7CFF7C' : '#FF8080'; ascensionHtml += ''; ascensionHtml += ''; if (spalten.stufe) ascensionHtml += ''; if (spalten.letzte) ascensionHtml += ''; if (spalten.kosten) ascensionHtml += ''; if (spalten.invest) ascensionHtml += ''; ascensionHtml += ''; }); ascensionHtml += '
StLKI
' + (kaufbar ? '✅ ' : '❌ ') + esc(e.name) + '' + esc(e.stufe ?? '-') + '' + esc(e.letzte ?? '-') + '' + esc(e.kostenText || 'N/A') + '' + esc(e.invest ?? '-') + '
'; ascensionHtml += '
'; ascensionHtml += ''; ascensionHtml += ''; ascensionHtml += '
'; } html += baueNavigationHtml(); html += baueAccordionBereichHtml('ascension', '⭐ Ascension', ascensionHtml); html += baueAccordionBereichHtml('baualarm', '🏗 Baualarm', baueBaualarmHtml(ziel)); html += baueAccordionBereichHtml('forschungsalarm', '🔬 Forschungsalarm', baueForschungsalarmHtml(forschung)); html += baueAccordionBereichHtml('techtree', '🧭 Forschungsplanung', baueTechTreeTestHtml()); } box.innerHTML = html; positioniereBox(box); const auswahl = document.getElementById('haa-auswahl'); if (auswahl) auswahl.onclick = function () { baueAuswahl(datenAnzeige, favoriten); }; const spaltenBtn = document.getElementById('haa-spalten'); if (spaltenBtn) spaltenBtn.onclick = baueSpalten; verbindeHasaUiButtons(baueBox); verbindeBaualarmButtons(ziel, baueBox); verbindeForschungsalarmButtons(forschung, baueBox); verbindeTechTreeTestButtons(); aktualisiereBauMarkierungen(); } function baueAuswahl(daten, favoriten) { const box = grundBox(); let html = '
Favoriten auswählen
'; html += '
'; let letzteKat = ''; daten.forEach(e => { if (e.kategorie !== letzteKat) { letzteKat = e.kategorie; html += '
' + esc(letzteKat) + '
'; } html += ''; }); html += '
'; html += ''; html += ''; box.innerHTML = html; positioniereBox(box); document.getElementById('haa-save').onclick = function () { const namen = Array.from(document.querySelectorAll('.haa-check:checked')).map(e => e.value); speichern(STORAGE_FAVORITEN, namen); baueBox(); }; document.getElementById('haa-back').onclick = baueBox; } function baueSpalten() { const box = grundBox(); const spalten = ladeSpalten(); box.innerHTML = '
Spalten anzeigen
' + checkbox('stufe', 'St = Stufe', spalten.stufe) + checkbox('letzte', 'L = letzte Kosten', spalten.letzte) + checkbox('kosten', 'K = nächste Kosten', spalten.kosten) + checkbox('invest', 'I = insgesamt investiert', spalten.invest) + '' + ''; positioniereBox(box); document.getElementById('haa-save-spalten').onclick = function () { const neu = {}; ['stufe', 'letzte', 'kosten', 'invest'].forEach(k => { neu[k] = document.getElementById('haa-col-' + k).checked; }); speichern(STORAGE_SPALTEN, neu); baueBox(); }; document.getElementById('haa-back').onclick = baueBox; } function checkbox(key, label, checked) { return ''; } let HAA_BAUALARM_TIMER = null; let HAA_BAUALARM_ANZEIGE_TIMER = null; let HAA_FORSCHUNGSALARM_TIMER = null; let HAA_FORSCHUNGSANZEIGE_TIMER = null; let HAA_AUDIO_CTX = null; let HAA_AUDIO_FREIGEGEBEN = false; let HAA_START_GELAUFEN = false; let HAA_DRAG_AKTIV = false; let HAA_DRAG_START_X = 0; let HAA_DRAG_START_Y = 0; let HAA_DRAG_BOX_X = 0; let HAA_DRAG_BOX_Y = 0; let HAA_DRAG_BOX = null; let HAA_VORALARM_AUSGELOEST_LAUF = ''; let HAA_UEBERWACHUNG_LETZTE_SYNC = 0; let HAA_AUDIO_KEEPALIVE_TIMER = null; let HAA_ALARM_BANNER_TIMER = null; let HAA_SYNC_LAEUFT = false; let HAA_LETZTE_SCAN_DAUER_MS = 0; let HAA_AUDIO_KEEPALIVE_OSC = null; let HAA_AUDIO_KEEPALIVE_GAIN = null; let HAA_AUDIO_NACHHOLEN = 0; let HAA_UI_BOX_ZU = laden(STORAGE_UI_BOX_ZU, '0') === '1'; let HAA_UI_AKTIVER_BEREICH = laden(STORAGE_UI_AKTIVER_BEREICH, 'ascension') || 'ascension'; /* * HASA 0.8 Beobachter-Engine * * Grundregel: * HASA besitzt keine eigene Bauzeit mehr. * Horizon ist die Uhr. * * Der Timer ist nur ein Taktgeber: * - sichtbare Horizon-Zeit lesen * - Text anzeigen * - Sekundenwert nur fuer Voralarm pruefen * * Kein Date.now()-Countdown, keine Zielzeit als Wahrheit. */ const HAA_CC_NAMEN = [ 'Koloniezentrum', 'Großes Koloniezentrum', 'Grosses Koloniezentrum', 'Colonycenter', 'Colony Center', 'Kolonialverwaltung', 'Provisorische Kolonialverwaltung' ]; function istBaualarmAn() { return laden(STORAGE_BAUALARM, '0') === '1'; } function setBaualarmAn(an) { speichern(STORAGE_BAUALARM, an ? '1' : '0'); if (!an && HAA_BAUALARM_TIMER) { clearInterval(HAA_BAUALARM_TIMER); HAA_BAUALARM_TIMER = null; } } function istCcGebaeude(name) { const n = String(name || '').toLowerCase(); return HAA_CC_NAMEN.some(cc => n.includes(cc.toLowerCase())); } function normalisiereGebaeudename(name) { const n = String(name || '') .replace(/^[\s○◉●◌◎⊙]+/, '') .replace(/\s+/g, ' ') .trim(); if (!n || /^countdown$/i.test(n) || /^bauauftrag$/i.test(n)) { return 'Bauauftrag'; } return n; } function zeitStringZuSekunden(zeit) { const m = String(zeit || '').match(/(\d+):(\d{2}):(\d{2}):(\d{2})s?/); if (!m) { return null; } const tage = Number(m[1]); const stunden = Number(m[2]); const minuten = Number(m[3]); const sekunden = Number(m[4]); return (((tage * 24) + stunden) * 60 + minuten) * 60 + sekunden; } function kuerzeZeitText(zeit) { const m = String(zeit || '').match(/(\d+):(\d{2}):(\d{2}):(\d{2})s?/); if (!m) { return String(zeit || '').replace(/s$/, ''); } const tage = Number(m[1]); const stunden = Number(m[2]); const minuten = Number(m[3]); const sekunden = Number(m[4]); if (tage > 0) { return tage + ':' + String(stunden).padStart(2, '0') + ':' + String(minuten).padStart(2, '0') + ':' + String(sekunden).padStart(2, '0'); } if (stunden > 0) { return stunden + ':' + String(minuten).padStart(2, '0') + ':' + String(sekunden).padStart(2, '0'); } return minuten + ':' + String(sekunden).padStart(2, '0'); } function merkeLetztenBauStatus(info) { if (!info || info.sekunden === null || info.sekunden === undefined) { return; } speichern(STORAGE_BAUSTATUS, { planet: info.planet || 'aktueller Planet', gebaeude: normalisiereGebaeudename(info.name || info.gebaeude), modus: info.modus || 'Bau', sekunden: Number(info.sekunden), zeitText: info.zeitText || formatSekunden(info.sekunden), zuletztGesehen: Date.now(), quelle: info.quelle || 'sichtbar' }); } function ladeLetztenBauStatus() { const daten = laden(STORAGE_BAUSTATUS, null); if (!daten || daten.sekunden === null || daten.sekunden === undefined) { return null; } return { planet: daten.planet || 'aktueller Planet', name: normalisiereGebaeudename(daten.gebaeude), gebaeude: normalisiereGebaeudename(daten.gebaeude), modus: daten.modus || 'Bau', sekunden: Number(daten.sekunden), zeitText: daten.zeitText || formatSekunden(Number(daten.sekunden)), sichtbar: false, wartet: true, quelle: 'letzter-sichtbarer-wert' }; } function baueBauInfo(name, zeit, quelle) { const sekunden = zeitStringZuSekunden(zeit); if (sekunden === null || sekunden < 0) { return null; } return { name: normalisiereGebaeudename(name), gebaeude: normalisiereGebaeudename(name), planet: 'aktueller Planet', modus: 'Bau', sekunden: sekunden, zeitText: kuerzeZeitText(zeit), sichtbar: true, wartet: false, quelle: quelle }; } function findeBauInfoAusZeitZeilen(zeilen) { for (let i = 0; i < zeilen.length; i++) { const z = zeilen[i]; const mZeit = z.match(/^Zeit:\s*(\d+:\d{2}:\d{2}:\d{2}s?)\s*$/i); if (!mZeit) { continue; } let name = 'Koloniezentrum'; let rohName = ''; for (let j = i - 1; j >= 0 && j >= i - 10; j--) { if (/wird gebaut/i.test(zeilen[j])) { rohName = zeilen[j].replace(/\s*wird gebaut\..*$/i, '').trim(); name = rohName || name; break; } } const kontext = (rohName + ' ' + zeilen.slice(Math.max(0, i - 10), i + 1).join(' ')).toLowerCase(); if (istCcGebaeude(name) || /colonycenter|koloniezentrum|kolonialverwaltung/i.test(kontext)) { return baueBauInfo(name, mZeit[1], 'zeit-zeile'); } } return null; } function findeBauInfoAusKategorieZeilen(bodyText) { const reKlammerBau = /\(([^()\n]+?)\s*:\s*(\d+:\d{2}:\d{2}:\d{2}s?)\s*\)/g; let match; let bester = null; while ((match = reKlammerBau.exec(bodyText)) !== null) { const name = match[1].trim(); if (!istCcGebaeude(name) && !/colonycenter|koloniezentrum|kolonialverwaltung/i.test(name)) { continue; } const info = baueBauInfo(name, match[2], 'kategorie-zeile'); if (info && (!bester || info.sekunden < bester.sekunden)) { bester = info; } } return bester; } function findeSichtbarenBauStatus() { const bodyText = document.body ? document.body.innerText : ''; if (!bodyText) { return null; } const zeilen = bodyText.split(/\n+/).map(z => z.replace(/\s+/g, ' ').trim()).filter(Boolean); return findeBauInfoAusZeitZeilen(zeilen) || findeBauInfoAusKategorieZeilen(bodyText); } function aktualisiereBauStatusAusSeite() { const live = findeSichtbarenBauStatus(); if (live) { merkeLetztenBauStatus(live); return live; } return ladeLetztenBauStatus(); } function berechneGespeicherteRestsekunden(daten) { if (!daten) { return null; } const basisSekunden = Number( daten.restBasisSekunden !== undefined ? daten.restBasisSekunden : daten.sekunden ); const basisZeit = Number( daten.restBasisZeit !== undefined ? daten.restBasisZeit : daten.zuletztGesehen ); if (!Number.isFinite(basisSekunden)) { return null; } if (!Number.isFinite(basisZeit) || daten.status !== 'laeuft') { return Math.max(0, basisSekunden); } const vergangen = Math.max(0, Math.floor((Date.now() - basisZeit) / 1000)); return Math.max(0, basisSekunden - vergangen); } function ladeBauUeberwachung() { const daten = laden(STORAGE_BAUUEBERWACHUNG, null); if (!daten || typeof daten !== 'object' || Array.isArray(daten)) { return null; } const name = normalisiereGebaeudename(daten.name || daten.gebaeude || ''); if (!name || name === 'Bauauftrag') { return null; } const sekunden = berechneGespeicherteRestsekunden(daten); return { typ: daten.typ || 'ziel', bereich: daten.bereich || '', planet: daten.planet || 'Planet nicht erkannt', name: name, gebaeude: name, status: daten.status || (sekunden !== null ? 'laeuft' : 'vorgemerkt'), sekunden: sekunden, zeitText: sekunden !== null ? formatSekunden(sekunden) : '', sichtbar: false, wartet: false, quelle: daten.quelle || 'gespeicherte-ueberwachung', gestartetAm: daten.gestartetAm || null, ausgewaehltAm: daten.ausgewaehltAm || Date.now(), zuletztGesehen: daten.zuletztGesehen || Date.now(), restBasisSekunden: Number.isFinite(Number(daten.restBasisSekunden)) ? Number(daten.restBasisSekunden) : sekunden, restBasisZeit: Number.isFinite(Number(daten.restBasisZeit)) ? Number(daten.restBasisZeit) : Date.now(), letzteLiveSekunden: Number.isFinite(Number(daten.letzteLiveSekunden)) ? Number(daten.letzteLiveSekunden) : null }; } function speichereBauUeberwachung(info) { if (!info) { return null; } const sekundenRoh = Number(info.sekunden); const sekunden = Number.isFinite(sekundenRoh) ? Math.max(0, sekundenRoh) : null; const status = info.status === 'laeuft' ? 'laeuft' : 'vorgemerkt'; const jetzt = Date.now(); const ziel = { typ: info.typ || 'ziel', bereich: info.bereich || ermittleAktuellenBereich(), planet: info.planet || findeAktuellenPlanetennamen() || 'Planet nicht erkannt', name: normalisiereGebaeudename(info.name || info.gebaeude || 'Bauauftrag'), gebaeude: normalisiereGebaeudename(info.name || info.gebaeude || 'Bauauftrag'), status: status, sekunden: sekunden, zeitText: sekunden !== null ? formatSekunden(sekunden) : '', quelle: info.quelle || 'benutzer-markierung', gestartetAm: status === 'laeuft' ? jetzt : null, ausgewaehltAm: jetzt, zuletztGesehen: jetzt, restBasisSekunden: status === 'laeuft' ? sekunden : null, restBasisZeit: status === 'laeuft' ? jetzt : null, letzteLiveSekunden: status === 'laeuft' ? sekunden : null }; speichern(STORAGE_BAUUEBERWACHUNG, ziel); HAA_UEBERWACHUNG_LETZTE_SYNC = 0; setBaualarmAn(true); speichern(STORAGE_ALARM_AUSGELOEST, ''); speichern(STORAGE_VORALARM_AUSGELOEST, ''); HAA_VORALARM_AUSGELOEST_LAUF = ''; return ziel; } function beendeBauUeberwachung() { speichern(STORAGE_BAUUEBERWACHUNG, null); HAA_UEBERWACHUNG_LETZTE_SYNC = 0; setBaualarmAn(false); speichern(STORAGE_ALARM_AUSGELOEST, ''); speichern(STORAGE_VORALARM_AUSGELOEST, ''); HAA_VORALARM_AUSGELOEST_LAUF = ''; aktualisiereBauMarkierungen(); } function bauInfoPasstZuUeberwachung(info, ziel) { if (!info || !ziel) { return false; } const a = normalisiereGebaeudename(info.name || info.gebaeude).toLowerCase(); const b = normalisiereGebaeudename(ziel.name || ziel.gebaeude).toLowerCase(); const planetA = String(info.planet || '').trim().toLowerCase(); const planetB = String(ziel.planet || '').trim().toLowerCase(); if (!a || !b || !(a === b || a.includes(b) || b.includes(a))) { return false; } const istForschung = String(info.typ || ziel.typ || '').toLowerCase() === 'forschung'; // Forschung gilt reichsweit. Der Planet entscheidet deshalb nicht, // ob ein gespeicherter Forschungsauftrag zum sichtbaren Auftrag passt. if (!istForschung && planetA && planetB && planetA !== 'planet nicht erkannt' && planetB !== 'planet nicht erkannt' && planetA !== planetB) { return false; } const bereichA = String(info.bereich || '').toLowerCase(); const bereichB = String(ziel.bereich || '').toLowerCase(); if (bereichA && bereichB && bereichA !== bereichB) { return false; } return true; } function escapeRegExp(textWert) { return String(textWert || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function findeGlobalenZielZeitwert(ziel) { if (!ziel || !ziel.name) { return null; } const name = normalisiereGebaeudename(ziel.name); const nameRe = new RegExp(escapeRegExp(name), 'i'); const zeitRe = /(\d+:\d{2}:\d{2}:\d{2}s?)/; const selektoren = [ 'tr', '.BldClsRow', '.BldClsBody', '[class*="event" i]', '[id*="event" i]', '[class*="order" i]', '[id*="order" i]' ]; const elemente = Array.from(document.querySelectorAll(selektoren.join(','))) .slice(0, 1200); let bester = null; for (const el of elemente) { if (!el || el.closest('#haa-box') || el.closest('.haa-watch-circle')) { continue; } const t = text(el); if (!t || t.length > 350 || !nameRe.test(t)) { continue; } const m = t.match(zeitRe); if (!m) { continue; } const sekunden = zeitStringZuSekunden(m[1]); if (sekunden === null) { continue; } if (!bester || sekunden < bester.sekunden) { bester = { sekunden: sekunden, zeitText: kuerzeZeitText(m[1]), element: el }; } } return bester; } function holeUeberwachungsStatusMitSync(force) { const jetzt = Date.now(); if (!force && HAA_UEBERWACHUNG_LETZTE_SYNC && jetzt - HAA_UEBERWACHUNG_LETZTE_SYNC < HASA_SYNC_INTERVALL_MS) { return ladeBauUeberwachung(); } if (HAA_SYNC_LAEUFT) { return ladeBauUeberwachung(); } HAA_SYNC_LAEUFT = true; const startZeit = performance.now(); try { HAA_UEBERWACHUNG_LETZTE_SYNC = jetzt; return aktualisiereUeberwachungsStatus(); } finally { HAA_LETZTE_SCAN_DAUER_MS = Math.round(performance.now() - startZeit); HAA_SYNC_LAEUFT = false; if (HAA_LETZTE_SCAN_DAUER_MS > 250) { console.warn( 'HASA Performance: Statusscan dauerte ' + HAA_LETZTE_SCAN_DAUER_MS + ' ms.' ); } } } function aktualisiereUeberwachungsStatus() { const ziel = ladeBauUeberwachung(); if (!ziel) { return null; } const kandidaten = findeBauKandidaten(); const live = kandidaten.find(function (k) { return bauInfoPasstZuUeberwachung(k.info, ziel); }); if (live) { const info = live.info; if (ziel.status === 'laeuft' && info.status === 'baubar') { const fertig = Object.assign({}, ziel, { status: 'fertig', sekunden: 0, zeitText: '0:00', sichtbar: true, wartet: false, quelle: 'ziel-fertig-erkannt', zuletztGesehen: Date.now() }); loeseBaualarmAus(fertig); speichern(STORAGE_BAUUEBERWACHUNG, null); setBaualarmAn(false); speichern(STORAGE_VORALARM_AUSGELOEST, ''); HAA_VORALARM_AUSGELOEST_LAUF = ''; setTimeout(function () { aktualisiereBauMarkierungen(); baueBox(); }, 250); return null; } if (info.status === 'laeuft' && Number.isFinite(Number(info.sekunden))) { const liveSekunden = Math.max(0, Number(info.sekunden)); const vorherLive = Number(ziel.letzteLiveSekunden); const jetzt = Date.now(); // Horizon bleibt die Referenz, sobald dort ein kleinerer Wert erscheint. // Bleibt der DOM-Wert statisch, läuft HASA lokal sekündlich weiter. const mussNeuSynchronisieren = ziel.status !== 'laeuft' || !Number.isFinite(Number(ziel.restBasisSekunden)) || !Number.isFinite(Number(ziel.restBasisZeit)) || (Number.isFinite(vorherLive) && liveSekunden < vorherLive); const neu = Object.assign({}, ziel, info, { name: normalisiereGebaeudename(info.name || ziel.name), gebaeude: normalisiereGebaeudename(info.name || ziel.name), planet: info.planet || ziel.planet, bereich: info.bereich || ziel.bereich, status: 'laeuft', sichtbar: true, wartet: false, quelle: info.quelle || 'explorer', gestartetAm: ziel.gestartetAm || jetzt, zuletztGesehen: jetzt, letzteLiveSekunden: liveSekunden }); if (mussNeuSynchronisieren) { neu.restBasisSekunden = liveSekunden; neu.restBasisZeit = jetzt; neu.sekunden = liveSekunden; } else { neu.restBasisSekunden = ziel.restBasisSekunden; neu.restBasisZeit = ziel.restBasisZeit; neu.sekunden = berechneGespeicherteRestsekunden(neu); } neu.zeitText = formatSekunden(neu.sekunden); speichern(STORAGE_BAUUEBERWACHUNG, neu); return neu; } const neu = Object.assign({}, ziel, info, { name: normalisiereGebaeudename(info.name || ziel.name), gebaeude: normalisiereGebaeudename(info.name || ziel.name), planet: info.planet || ziel.planet, bereich: info.bereich || ziel.bereich, status: 'vorgemerkt', sekunden: null, zeitText: '', sichtbar: true, wartet: false, quelle: info.quelle || 'explorer', zuletztGesehen: Date.now(), restBasisSekunden: null, restBasisZeit: null, letzteLiveSekunden: null }); speichern(STORAGE_BAUUEBERWACHUNG, neu); return neu; } const global = findeGlobalenZielZeitwert(ziel); if (global && ziel.status === 'laeuft') { const jetzt = Date.now(); const globalSekunden = Math.max(0, Number(global.sekunden)); const synchronisiert = Object.assign({}, ziel, { sekunden: globalSekunden, zeitText: formatSekunden(globalSekunden), sichtbar: true, wartet: false, quelle: 'globaler-statusscanner', zuletztGesehen: jetzt, restBasisSekunden: globalSekunden, restBasisZeit: jetzt, letzteLiveSekunden: globalSekunden }); speichern(STORAGE_BAUUEBERWACHUNG, synchronisiert); return synchronisiert; } // Auch ohne sichtbare Quelle läuft ein bereits gestarteter Countdown lokal weiter. const lokalSekunden = berechneGespeicherteRestsekunden(ziel); return Object.assign({}, ziel, { sekunden: lokalSekunden, zeitText: Number.isFinite(Number(lokalSekunden)) ? formatSekunden(lokalSekunden) : '', sichtbar: false, wartet: false, quelle: 'lokaler-fortlauf' }); } function formatSekunden(sec) { sec = Math.max(0, Math.floor(sec)); const d = Math.floor(sec / 86400); const h = Math.floor((sec % 86400) / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; if (d > 0) return d + ':' + String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); return m + ':' + String(s).padStart(2, '0'); } function zeigeBaualarmStatus(info) { const restEl = document.getElementById('haa-baualarm-rest'); if (!restEl) { return; } if (!info) { restEl.textContent = 'unbekannt'; return; } if (info.status !== 'laeuft' || !Number.isFinite(Number(info.sekunden))) { restEl.textContent = 'noch nicht gestartet'; } else { restEl.textContent = info.zeitText || formatSekunden(info.sekunden); } } function starteBaualarmAnzeigeTicker(info) { if (HAA_BAUALARM_ANZEIGE_TIMER) { clearInterval(HAA_BAUALARM_ANZEIGE_TIMER); HAA_BAUALARM_ANZEIGE_TIMER = null; } function tick() { const restEl = document.getElementById('haa-baualarm-rest'); if (!restEl) { clearInterval(HAA_BAUALARM_ANZEIGE_TIMER); HAA_BAUALARM_ANZEIGE_TIMER = null; return; } zeigeBaualarmStatus(holeUeberwachungsStatusMitSync(false)); } zeigeBaualarmStatus(holeUeberwachungsStatusMitSync(true)); HAA_BAUALARM_ANZEIGE_TIMER = setInterval(tick, 1000); } function holeAudioContext() { const AudioCtx = window.AudioContext || window.webkitAudioContext; if (!AudioCtx) { return null; } if (!HAA_AUDIO_CTX || HAA_AUDIO_CTX.state === 'closed') { HAA_AUDIO_CTX = new AudioCtx(); } return HAA_AUDIO_CTX; } function istAudioAktiv() { return !!( HAA_AUDIO_CTX && HAA_AUDIO_CTX.state === 'running' && HAA_AUDIO_FREIGEGEBEN ); } function starteAudioKeepalive() { const ctx = holeAudioContext(); if (!ctx || ctx.state !== 'running' || HAA_AUDIO_KEEPALIVE_OSC) { return; } try { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; osc.frequency.value = 18; gain.gain.value = 0.000001; osc.connect(gain); gain.connect(ctx.destination); osc.start(); HAA_AUDIO_KEEPALIVE_OSC = osc; HAA_AUDIO_KEEPALIVE_GAIN = gain; } catch (e) { console.warn('HASA Audio-Keepalive fehlgeschlagen:', e); } } async function schalteAudioFrei() { const ctx = holeAudioContext(); if (!ctx) { HAA_AUDIO_FREIGEGEBEN = false; return false; } try { if (ctx.state === 'suspended') { await ctx.resume(); } if (ctx.state !== 'running') { HAA_AUDIO_FREIGEGEBEN = false; return false; } const osc = ctx.createOscillator(); const gain = ctx.createGain(); gain.gain.value = 0.0001; osc.connect(gain); gain.connect(ctx.destination); osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.03); HAA_AUDIO_FREIGEGEBEN = true; starteAudioKeepalive(); return true; } catch (e) { HAA_AUDIO_FREIGEGEBEN = false; console.warn('HASA Audiofreigabe fehlgeschlagen:', e); return false; } } async function spieleNachgeholtenAlarm() { if (!HAA_AUDIO_NACHHOLEN || !istAudioAktiv()) { return; } const anzahl = HAA_AUDIO_NACHHOLEN; HAA_AUDIO_NACHHOLEN = 0; for (let i = 0; i < anzahl; i++) { setTimeout(function () { hasaBeep(); }, i * 550); } } function installiereAudioFreigabe() { const handler = function () { schalteAudioFrei().then(function (ok) { if (ok) { spieleNachgeholtenAlarm(); } }); }; window.addEventListener('pointerdown', handler, { capture: true, passive: true }); window.addEventListener('mousedown', handler, { capture: true, passive: true }); window.addEventListener('contextmenu', handler, { capture: true, passive: true }); window.addEventListener('keydown', handler, { capture: true, passive: true }); window.addEventListener('touchstart', handler, { capture: true, passive: true }); document.addEventListener('visibilitychange', function () { if (!document.hidden && HAA_AUDIO_CTX && HAA_AUDIO_CTX.state === 'suspended') { HAA_AUDIO_CTX.resume().then(function () { HAA_AUDIO_FREIGEGEBEN = HAA_AUDIO_CTX.state === 'running'; if (HAA_AUDIO_FREIGEGEBEN) { starteAudioKeepalive(); spieleNachgeholtenAlarm(); } }).catch(function () {}); } }); } async function hasaBeep() { try { const ctx = holeAudioContext(); if (!ctx) { HAA_AUDIO_FREIGEGEBEN = false; return false; } if (ctx.state === 'suspended') { try { await ctx.resume(); } catch (e) { HAA_AUDIO_FREIGEGEBEN = false; return false; } } if (ctx.state !== 'running') { HAA_AUDIO_FREIGEGEBEN = false; return false; } function ton(start, freq, dauer, laut) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'square'; osc.frequency.value = freq; gain.gain.setValueAtTime(0.0001, ctx.currentTime + start); gain.gain.exponentialRampToValueAtTime(laut, ctx.currentTime + start + 0.02); gain.gain.setValueAtTime(laut, ctx.currentTime + start + Math.max(0.03, dauer - 0.05)); gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + start + dauer); osc.connect(gain); gain.connect(ctx.destination); osc.start(ctx.currentTime + start); osc.stop(ctx.currentTime + start + dauer + 0.02); } // HASA 1.0.3a: ein einzelner kurzer Alarmton. ton(0.00, 740, 0.28, 0.24); HAA_AUDIO_FREIGEGEBEN = true; starteAudioKeepalive(); return true; } catch (e) { HAA_AUDIO_FREIGEGEBEN = false; console.warn('HASA Ton konnte nicht abgespielt werden:', e); return false; } } async function spieleAlarmfolge(anzahl) { const ok = await hasaBeep(); if (!ok) { HAA_AUDIO_NACHHOLEN = Math.max(HAA_AUDIO_NACHHOLEN, 1); return false; } return true; } function zeigeHasaAlarmBanner(textWert, kritisch) { const alt = document.getElementById('haa-alarm-banner'); if (alt) { alt.remove(); } if (HAA_ALARM_BANNER_TIMER) { clearTimeout(HAA_ALARM_BANNER_TIMER); HAA_ALARM_BANNER_TIMER = null; } const banner = document.createElement('div'); banner.id = 'haa-alarm-banner'; banner.textContent = textWert; banner.title = 'Zum Schließen anklicken'; banner.style.position = 'fixed'; banner.style.zIndex = '1000001'; banner.style.left = '50%'; banner.style.top = '18px'; banner.style.transform = 'translateX(-50%)'; banner.style.maxWidth = '760px'; banner.style.padding = '12px 18px'; banner.style.border = kritisch ? '3px solid #ff5a5a' : '2px solid #FFD27C'; banner.style.borderRadius = '6px'; banner.style.background = kritisch ? '#4a1515' : '#211d18'; banner.style.color = '#fff'; banner.style.fontFamily = 'Arial, sans-serif'; banner.style.fontSize = '16px'; banner.style.fontWeight = 'bold'; banner.style.boxShadow = '0 0 16px #000'; banner.style.cursor = 'pointer'; banner.onclick = function () { banner.remove(); if (HAA_ALARM_BANNER_TIMER) { clearTimeout(HAA_ALARM_BANNER_TIMER); HAA_ALARM_BANNER_TIMER = null; } }; document.body.appendChild(banner); HAA_ALARM_BANNER_TIMER = setTimeout(function () { if (banner && banner.parentNode) { banner.remove(); } HAA_ALARM_BANNER_TIMER = null; }, kritisch ? 30000 : 15000); } function alarmKey(info) { if (!info) { return ''; } return [ normalisiereGebaeudename(info.name || 'ziel'), info.planet || '', info.bereich || '', String(info.gestartetAm || info.ausgewaehltAm || '') ].join('|'); } function voralarmKey(info, vorlaufSekunden) { if (!info) { return ''; } return [info.name || 'bau', String(vorlaufSekunden || 0)].join('|'); } function loeseVoralarmAus(info, vorlaufSekunden) { const key = voralarmKey(info, vorlaufSekunden); if (!key) { return; } if (HAA_VORALARM_AUSGELOEST_LAUF === key || laden(STORAGE_VORALARM_AUSGELOEST, '') === key) { return; } HAA_VORALARM_AUSGELOEST_LAUF = key; speichern(STORAGE_VORALARM_AUSGELOEST, key); const istForschung = info && info.typ === 'forschung'; spieleAlarmfolge(1); document.title = istForschung ? '🔬 HASA Forschungsvoralarm - Horizon' : '🔔 HASA Voralarm - Horizon'; zeigeHasaAlarmBanner( (istForschung ? 'HASA Forschungsvoralarm: ' : 'HASA Voralarm: ') + (info && info.name ? info.name : (istForschung ? 'Forschung' : 'Ziel')) + ' ist bald fertig.', false ); } function loeseBaualarmAus(info) { const key = alarmKey(info); if (key && laden(STORAGE_ALARM_AUSGELOEST, '') === key) { return; } if (key) { speichern(STORAGE_ALARM_AUSGELOEST, key); } const istForschung = info && info.typ === 'forschung'; spieleAlarmfolge(1); document.title = istForschung ? '🔬 Forschung fertig - Horizon' : '🔔 Ziel fertig - Horizon'; zeigeHasaAlarmBanner( (istForschung ? 'HASA Forschungsalarm: ' : 'HASA Alarm: ') + (info && info.name ? info.name : (istForschung ? 'Forschung' : 'Ziel')) + ' ist fertig.', true ); } function starteBaualarmWennNoetig(info) { if (HAA_BAUALARM_TIMER) { clearInterval(HAA_BAUALARM_TIMER); HAA_BAUALARM_TIMER = null; } if (!istBaualarmAn() || !ladeBauUeberwachung()) { return; } HAA_BAUALARM_TIMER = setInterval(function () { if (!istBaualarmAn() || !ladeBauUeberwachung()) { clearInterval(HAA_BAUALARM_TIMER); HAA_BAUALARM_TIMER = null; return; } const aktuell = holeUeberwachungsStatusMitSync(false); if (!aktuell || aktuell.status !== 'laeuft' || !Number.isFinite(Number(aktuell.sekunden))) { return; } const vorlauf = ladeVorlaufSekunden(); if (vorlauf > 0 && aktuell.sekunden > HASA_ALARM_VORLAUF_SEKUNDEN && aktuell.sekunden <= vorlauf) { loeseVoralarmAus(aktuell, vorlauf); } if (vorlauf > 0 && aktuell.sekunden > vorlauf + 10) { HAA_VORALARM_AUSGELOEST_LAUF = ''; speichern(STORAGE_VORALARM_AUSGELOEST, ''); } if (aktuell.sekunden <= HASA_ALARM_VORLAUF_SEKUNDEN) { clearInterval(HAA_BAUALARM_TIMER); HAA_BAUALARM_TIMER = null; loeseBaualarmAus(aktuell); beendeBauUeberwachung(); setTimeout(baueBox, 500); } }, 1000); } function baueBaualarmHtml(ziel) { const vorlauf = ladeVorlaufSekunden(); let html = ''; const istForschung = ziel && String(ziel.typ || '').toLowerCase() === 'forschung'; html += '
'; html += '
' + (istForschung ? '🔬 Forschungsalarm' : '🎯 Baualarm / Überwachung') + '
'; if (ziel) { html += '
'; html += '
'; html += '
'; html += '
' + esc(normalisiereGebaeudename(ziel.name)) + '
'; if (!istForschung) { html += '
' + esc(ziel.planet || 'Planet nicht erkannt') + '
'; } if (ziel.status === 'laeuft' && Number.isFinite(Number(ziel.sekunden))) { html += '
' + esc(formatSekunden(ziel.sekunden)) + '
'; } else { html += '
wartet
'; } html += '
'; html += ''; html += '
'; html += '
'; return html; } function verbindeBaualarmButtons(bau, neuAufbauen) { starteBaualarmAnzeigeTicker(bau); starteBaualarmWennNoetig(bau); const alarmBtn = document.getElementById('haa-alarm-toggle'); if (alarmBtn) { alarmBtn.onclick = async function () { const zielAktuell = ladeBauUeberwachung(); if (zielAktuell) { beendeBauUeberwachung(); neuAufbauen(); return; } await schalteAudioFrei(); neuAufbauen(); }; } const zielKreis = document.getElementById('haa-ziel-kreis'); if (zielKreis) { zielKreis.onclick = async function () { const frei = await schalteAudioFrei(); if (frei) starteAudioKeepalive(); if (ladeBauUeberwachung()) beendeBauUeberwachung(); neuAufbauen(); }; } const tonBtn = document.getElementById('haa-ton-test'); if (tonBtn) { tonBtn.onclick = async function () { await schalteAudioFrei(); await hasaBeep(); neuAufbauen(); }; } verbindeZielUndVorlaufButtons(neuAufbauen); } function entferneBauMarkierungen() { document.querySelectorAll('.haa-watch-circle').forEach(function (el) { el.remove(); }); } function ermittleAktuellenBereich() { const aktiv = document.querySelector('.NavLinkActive'); if (aktiv && aktiv.id) { return aktiv.id.replace(/^nav_/, '').toLowerCase(); } const m = String(location.search || '').match(/[?&]cmd=([^&]+)/i); return m ? decodeURIComponent(m[1]).toLowerCase() : 'unbekannt'; } function findeAktuellenPlanetennamen() { const selected = document.querySelector('select.PlanetSelectBox option:checked'); if (selected) { const name = text(selected); if (name) return name; } const select = document.querySelector('select.PlanetSelectBox'); if (select && select.options && select.selectedIndex >= 0) { const option = select.options[select.selectedIndex]; const name = text(option); if (name) return name; } return 'Planet nicht erkannt'; } function istElementSichtbar(el) { if (!el || !el.getBoundingClientRect) return false; const r = el.getBoundingClientRect(); const cs = window.getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.display !== 'none' && cs.visibility !== 'hidden'; } function leseZeitAusText(textWert, bevorzugtLaufend) { const t = String(textWert || '').replace(/\s+/g, ' ').trim(); let m = null; if (bevorzugtLaufend) { m = t.match(/Zeit:\s*(\d+:\d{2}:\d{2}:\d{2}s?)/i); } if (!m) { m = t.match(/Bauzeit:\s*(\d+:\d{2}:\d{2}:\d{2}s?)/i); } if (!m) { m = t.match(/(\d+:\d{2}:\d{2}:\d{2}s?)/i); } return m ? m[1] : ''; } function leseGebaeudeNameAusKopf(kopf) { if (!kopf) return ''; const b = kopf.querySelector('b'); if (b) { const clone = b.cloneNode(true); clone.querySelectorAll('.haa-watch-circle').forEach(function (el) { el.remove(); }); const name = normalisiereGebaeudename(text(clone)); if (name) return name; } const clone = kopf.cloneNode(true); clone.querySelectorAll('.haa-watch-circle').forEach(function (el) { el.remove(); }); let roh = text(clone); roh = roh.replace(/\s*\([^)]*Ausbaustufe[^)]*\)\s*$/i, '').trim(); roh = roh.replace(/\s*wird gebaut.*$/i, '').trim(); return normalisiereGebaeudename(roh); } function findeKoerperZeileZumKopf(kopf) { const kopfZeile = kopf && kopf.closest ? kopf.closest('tr') : null; let el = kopfZeile ? kopfZeile.nextElementSibling : null; while (el) { if (el.classList && el.classList.contains('BldClsBody')) { return el; } if (el.querySelector && el.querySelector('.BldClsRow')) { break; } el = el.nextElementSibling; } return null; } function istAktiverBauenButton(body) { if (!body) return null; const links = Array.from(body.querySelectorAll('a.SystemGfxButton')); return links.find(function (a) { const klassen = a.classList; return /^bauen$/i.test(text(a)) && klassen.contains('Green') && !klassen.contains('SystemGfxButtonDisabled'); }) || null; } function istLaufenderBau(kopf, body) { const gesamt = (text(kopf) + ' ' + text(body)).replace(/\s+/g, ' '); const hatWirdGebaut = /wird gebaut/i.test(gesamt); const hatAbbrechen = Array.from((body || document).querySelectorAll('a, button')) .some(function (el) { return /^abbrechen$/i.test(text(el)); }); const hatZeit = /Zeit:\s*\d+:\d{2}:\d{2}:\d{2}s?/i.test(gesamt); return (hatWirdGebaut || hatAbbrechen) && hatZeit; } function findeBauKandidaten() { const planet = findeAktuellenPlanetennamen(); const kandidaten = []; const gesehen = {}; const koepfe = Array.from(document.querySelectorAll('.BldClsRow')); koepfe.forEach(function (kopf) { if (!kopf || kopf.closest('#haa-box')) return; const body = findeKoerperZeileZumKopf(kopf); if (!body) return; const name = normalisiereGebaeudename(leseGebaeudeNameAusKopf(kopf)); if (!name || name === 'Bauauftrag') return; const laufend = istLaufenderBau(kopf, body); const bauenButton = istAktiverBauenButton(body); if (!laufend && !bauenButton) { return; } const zeit = leseZeitAusText(text(kopf) + ' ' + text(body), laufend); if (!zeit) return; const sekunden = zeitStringZuSekunden(zeit); if (sekunden === null) return; const titelEl = kopf.querySelector('b') || kopf; const key = planet.toLowerCase() + '|' + name.toLowerCase(); if (gesehen[key]) return; gesehen[key] = true; const bereich = ermittleAktuellenBereich(); const typ = bereich === 'forschung' || bereich === 'research' ? 'forschung' : 'bau'; kandidaten.push({ key: key, kopf: kopf, body: body, titelEl: titelEl, info: { name: name, gebaeude: name, // Forschung ist global: kein Startplanet in Anzeige oder Zuordnung. planet: typ === 'forschung' ? '' : planet, bereich: bereich, typ: typ, status: laufend ? 'laeuft' : 'baubar', modus: 'Ziel', sekunden: laufend ? sekunden : null, bauzeitSekunden: laufend ? null : sekunden, zeitText: kuerzeZeitText(zeit), sichtbar: true, wartet: false, quelle: laufend ? 'horizon-bld-laufend' : 'horizon-bld-baubar' } }); }); return kandidaten; } function entferneBauMarkierungen() { document.querySelectorAll('.haa-watch-circle').forEach(function (el) { el.remove(); }); } function setzeKreisAnTitel(kandidat, ziel) { const titelEl = kandidat && kandidat.titelEl; if (!titelEl || !titelEl.parentNode) return; const markiert = ziel && bauInfoPasstZuUeberwachung(kandidat.info, ziel); const btn = document.createElement('button'); btn.className = 'haa-watch-circle'; btn.type = 'button'; btn.textContent = markiert ? '◉' : '○'; const istForschung = String(kandidat.info.typ || '').toLowerCase() === 'forschung'; btn.title = markiert ? 'Überwachung beenden: ' + kandidat.info.name : (istForschung ? 'Diese Forschung überwachen: ' + kandidat.info.name : 'Diesen Bau überwachen: ' + kandidat.info.name + ' auf ' + kandidat.info.planet); btn.style.display = 'inline-flex'; btn.style.alignItems = 'center'; btn.style.justifyContent = 'center'; btn.style.verticalAlign = 'middle'; btn.style.marginRight = '6px'; btn.style.width = '18px'; btn.style.height = '18px'; btn.style.lineHeight = '16px'; btn.style.padding = '0'; btn.style.borderRadius = '50%'; btn.style.border = '1px solid #FFD27C'; btn.style.background = '#211d18'; btn.style.color = markiert ? '#7CFF7C' : '#FFD27C'; btn.style.fontWeight = 'bold'; btn.style.cursor = 'pointer'; btn.style.boxShadow = '0 0 3px #000'; btn.onclick = function (e) { e.preventDefault(); e.stopPropagation(); schalteAudioFrei(); const aktuell = ladeBauUeberwachung(); if (aktuell && bauInfoPasstZuUeberwachung(kandidat.info, aktuell)) { beendeBauUeberwachung(); } else { speichereBauUeberwachung(kandidat.info); } baueBox(); setTimeout(aktualisiereBauMarkierungen, 80); }; titelEl.insertBefore(btn, titelEl.firstChild); } function aktualisiereBauMarkierungen() { entferneBauMarkierungen(); let ziel = ladeBauUeberwachung(); const kandidaten = findeBauKandidaten(); // Forschung wird unabhängig vom Bauziel gespeichert. Dadurch können // Baualarm und Forschungsalarm gleichzeitig sichtbar und aktiv bleiben. const laufendeForschungen = kandidaten.filter(function (kandidat) { return kandidat.info.typ === 'forschung' && kandidat.info.status === 'laeuft' && Number.isFinite(Number(kandidat.info.sekunden)); }); if (laufendeForschungen.length === 1) { speichereForschungsUeberwachung(laufendeForschungen[0].info); } kandidaten.forEach(function (kandidat) { setzeKreisAnTitel(kandidat, ziel); }); } /* * hasa 1.0.1b: Forschungsüberwachung läuft vollständig getrennt * von der Bauüberwachung. So können beide Aufträge parallel bestehen. */ function ladeForschungsUeberwachung() { const daten = laden(STORAGE_FORSCHUNGSUEBERWACHUNG, null); if (!daten || typeof daten !== 'object' || Array.isArray(daten)) return null; const name = normalisiereGebaeudename(daten.name || ''); if (!name || name === 'Bauauftrag') return null; const sekunden = berechneGespeicherteRestsekunden(daten); return Object.assign({}, daten, { typ: 'forschung', bereich: 'forschung', planet: '', name: name, gebaeude: name, sekunden: sekunden, zeitText: Number.isFinite(Number(sekunden)) ? formatSekunden(sekunden) : '' }); } function speichereForschungsUeberwachung(info) { if (!info) return null; const alt = ladeForschungsUeberwachung(); const name = normalisiereGebaeudename(info.name || info.gebaeude || 'Forschung'); const sekundenRoh = Number(info.sekunden); const sekunden = Number.isFinite(sekundenRoh) ? Math.max(0, sekundenRoh) : null; const jetzt = Date.now(); // Beim sekündlichen Seitenscan nicht jedes Mal den Startzeitpunkt erneuern. const gleicherAuftrag = alt && normalisiereGebaeudename(alt.name).toLowerCase() === name.toLowerCase(); const ziel = { typ: 'forschung', bereich: 'forschung', planet: '', name: name, gebaeude: name, status: sekunden !== null ? 'laeuft' : 'vorgemerkt', sekunden: sekunden, zeitText: sekunden !== null ? formatSekunden(sekunden) : '', quelle: info.quelle || 'forschungsseite', gestartetAm: gleicherAuftrag ? (alt.gestartetAm || jetzt) : jetzt, ausgewaehltAm: gleicherAuftrag ? (alt.ausgewaehltAm || jetzt) : jetzt, zuletztGesehen: jetzt, restBasisSekunden: sekunden, restBasisZeit: jetzt, letzteLiveSekunden: sekunden }; speichern(STORAGE_FORSCHUNGSUEBERWACHUNG, ziel); setBaualarmAn(true); return ziel; } function beendeForschungsUeberwachung() { speichern(STORAGE_FORSCHUNGSUEBERWACHUNG, null); if (HAA_FORSCHUNGSALARM_TIMER) { clearInterval(HAA_FORSCHUNGSALARM_TIMER); HAA_FORSCHUNGSALARM_TIMER = null; } } function migriereAlteForschungsUeberwachung() { const alt = ladeBauUeberwachung(); if (!alt || String(alt.typ || '').toLowerCase() !== 'forschung') return; speichereForschungsUeberwachung(alt); speichern(STORAGE_BAUUEBERWACHUNG, null); } function findeLaufendeForschungAusSeite() { const bodyText = document.body ? document.body.innerText : ''; if (!bodyText) return null; const zeilen = bodyText.split(/\n+/) .map(function (z) { return z.replace(/\s+/g, ' ').trim(); }) .filter(Boolean); const zeitRe = /(\d+:\d{2}:\d{2}:\d{2}s?)/; // Sicherster Hinweis auf der Imperiumsübersicht: // "Forschungsauftrag: Solarenergietechnik 9 0:02:29:53s" for (let i = 0; i < zeilen.length; i++) { if (!/Forschungsauftrag\s*:/i.test(zeilen[i])) continue; let gesamt = zeilen[i]; for (let j = 1; j <= 2 && i + j < zeilen.length; j++) { if (!zeitRe.test(gesamt)) gesamt += ' ' + zeilen[i + j]; } const m = gesamt.match(/Forschungsauftrag\s*:\s*(.+?)\s+(\d+:\d{2}:\d{2}:\d{2}s?)(?:\s|$)/i); if (!m) continue; const name = normalisiereGebaeudename(m[1]); const sekunden = zeitStringZuSekunden(m[2]); if (!name || sekunden === null) continue; return { typ: 'forschung', bereich: 'forschung', planet: '', name: name, gebaeude: name, status: 'laeuft', modus: 'Ziel', sekunden: sekunden, zeitText: kuerzeZeitText(m[2]), sichtbar: true, wartet: false, quelle: 'forschungsauftrag-zeile' }; } return null; } function findeForschungsZeitInEreignissen(ziel) { if (!ziel || !ziel.name) return null; const name = normalisiereGebaeudename(ziel.name); const nameRe = new RegExp(escapeRegExp(name), 'i'); const zeitRe = /(\d+:\d{2}:\d{2}:\d{2}s?)/g; const kandidaten = []; function fuegeKandidatHinzu(t, quelle, prioritaet) { if (!t || t.length > 300 || !nameRe.test(t)) return; const treffer = Array.from(t.matchAll(zeitRe)); if (!treffer.length) return; // Eine Horizon-Ereigniszeile enthält normalerweise genau eine Zeit. // Enthält ein großer Container mehrere Zeiten, ist er als Uhrquelle // ungeeignet und wird bewusst verworfen. if (treffer.length !== 1) return; const sekunden = zeitStringZuSekunden(treffer[0][1]); if (sekunden === null) return; kandidaten.push({ sekunden: sekunden, zeitText: kuerzeZeitText(treffer[0][1]), quelle: quelle, prioritaet: prioritaet, textLaenge: t.length }); } // 1. Vorrang: echte Tabellenzeilen der Ereignisliste. Array.from(document.querySelectorAll('tr')).forEach(function (el) { if (!el || el.closest('#haa-box')) return; fuegeKandidatHinzu(text(el), 'ereignis-tabellenzeile', 1); }); // 2. Danach nur kleine, zeilenartige Elemente. Keine großen DIV-Container, // weil diese mehrere Countdown-Zeiten enthalten und früher z. B. 9:09 // vom Bauauftrag fälschlich als Forschungszeit lieferten. Array.from(document.querySelectorAll('li, p, td, [class*="event" i], [id*="event" i]')).forEach(function (el) { if (!el || el.closest('#haa-box')) return; const r = el.getBoundingClientRect ? el.getBoundingClientRect() : null; if (r && r.height > 80) return; fuegeKandidatHinzu(text(el), 'ereignis-zeilenelement', 2); }); // 3. Sicherer Text-Fallback: sichtbare Zeilen einzeln prüfen. if (document.body) { document.body.innerText.split(/\n+/) .map(function (z) { return z.replace(/\s+/g, ' ').trim(); }) .filter(Boolean) .forEach(function (zeile) { fuegeKandidatHinzu(zeile, 'ereignis-textzeile', 3); }); } if (!kandidaten.length) return null; // Nicht die kleinste Zeit gewinnt, sondern die eindeutigste Horizon-Zeile. // Bei gleicher Quelle ist die kürzere Textzeile spezifischer. kandidaten.sort(function (a, b) { if (a.prioritaet !== b.prioritaet) return a.prioritaet - b.prioritaet; return a.textLaenge - b.textLaenge; }); return kandidaten[0]; } function aktualisiereForschungsStatus() { let ziel = ladeForschungsUeberwachung(); // Auf der Imperiumsübersicht steht die laufende Forschung ausdrücklich // hinter "Forschungsauftrag:". Diese Zeile hat Vorrang und funktioniert // unabhängig von den CSS-Klassen der Ereignisliste. const direkt = findeLaufendeForschungAusSeite(); if (direkt) { ziel = speichereForschungsUeberwachung(direkt); } // Auf der Forschungsseite übernimmt HASA die einzige laufende Forschung automatisch. const liveKandidaten = findeBauKandidaten().filter(function (kandidat) { return kandidat.info.typ === 'forschung' && kandidat.info.status === 'laeuft' && Number.isFinite(Number(kandidat.info.sekunden)); }); if (liveKandidaten.length === 1) { ziel = speichereForschungsUeberwachung(liveKandidaten[0].info); } if (!ziel) return null; // Auf allen Seiten wird anschließend die Ereignisliste als Horizon-Uhr benutzt. const ereignis = findeForschungsZeitInEreignissen(ziel); if (ereignis) { const jetzt = Date.now(); const neu = Object.assign({}, ziel, { status: 'laeuft', sekunden: ereignis.sekunden, zeitText: formatSekunden(ereignis.sekunden), sichtbar: true, quelle: 'ereignisliste', zuletztGesehen: jetzt, restBasisSekunden: ereignis.sekunden, restBasisZeit: jetzt, letzteLiveSekunden: ereignis.sekunden }); speichern(STORAGE_FORSCHUNGSUEBERWACHUNG, neu); return neu; } const lokal = berechneGespeicherteRestsekunden(ziel); return Object.assign({}, ziel, { sekunden: lokal, zeitText: Number.isFinite(Number(lokal)) ? formatSekunden(lokal) : '', sichtbar: false, quelle: 'lokaler-fortlauf' }); } function baueForschungsalarmHtml(forschung) { let html = ''; html += '
'; html += '
🔬 Forschungsalarm
'; if (forschung) { html += '
'; html += '
'; html += '
' + esc(forschung.name) + '
'; html += '
' + esc(Number.isFinite(Number(forschung.sekunden)) ? formatSekunden(forschung.sekunden) : 'wartet') + '
'; html += ''; html += '
'; } else { html += '
Keine laufende Forschung erkannt.
'; } html += '
'; return html; } function starteForschungsAnzeigeTicker() { if (HAA_FORSCHUNGSANZEIGE_TIMER) clearInterval(HAA_FORSCHUNGSANZEIGE_TIMER); HAA_FORSCHUNGSANZEIGE_TIMER = setInterval(function () { const el = document.getElementById('haa-forschungsalarm-rest'); if (!el) return; const aktuell = aktualisiereForschungsStatus(); el.textContent = aktuell && Number.isFinite(Number(aktuell.sekunden)) ? formatSekunden(aktuell.sekunden) : 'unbekannt'; }, 1000); } function starteForschungsalarmWennNoetig() { if (HAA_FORSCHUNGSALARM_TIMER) clearInterval(HAA_FORSCHUNGSALARM_TIMER); if (!ladeForschungsUeberwachung()) return; HAA_FORSCHUNGSALARM_TIMER = setInterval(function () { const aktuell = aktualisiereForschungsStatus(); if (!aktuell || !Number.isFinite(Number(aktuell.sekunden))) return; // Forschungsalarm: kein minutenfrüher Voralarm. Nur der Hauptalarm // kurz vor dem sichtbaren Ende bleibt aktiv. if (aktuell.sekunden <= HASA_ALARM_VORLAUF_SEKUNDEN) { loeseBaualarmAus(aktuell); beendeForschungsUeberwachung(); setTimeout(baueBox, 300); } }, 1000); } function verbindeForschungsalarmButtons(forschung, neuAufbauen) { starteForschungsAnzeigeTicker(); starteForschungsalarmWennNoetig(); const btn = document.getElementById('haa-forschungsziel-kreis'); if (btn) { btn.onclick = function () { beendeForschungsUeberwachung(); neuAufbauen(); }; } } let HAA_SEITENSCAN_TIMER = null; function planeTechTreeSeitenscan(verzoegerung) { clearTimeout(HAA_SEITENSCAN_TIMER); speichern(STORAGE_SEITENSCAN_STATUS, { state: 'red', message: 'sichtbare Horizon-Daten werden eingelesen', at: Date.now() }); baueBox(); HAA_SEITENSCAN_TIMER = setTimeout(function () { const bereich = ermittleAktuellenBereich(); const istDatenSeite = bereich === 'research' || bereich === 'forschung' || bereich === 'building' || bereich === 'gebäude' || bereich === 'shipyard'; if (istDatenSeite) speichereTechTreeSpielstandAusSeite(); speichern(STORAGE_SEITENSCAN_STATUS, { state: 'green', message: istDatenSeite ? 'sichtbare Horizon-Daten aktuell' : 'bereit', at: Date.now() }); baueBox(); }, Number(verzoegerung) || 350); } function istHorizonKlappkopf(element) { if (!element || !element.closest) return false; if (element.closest('#haa-box')) return false; return !!element.closest( '.BldContainerHeadBox, .ResContainerHeadBox, ' + '.BldAccordionHead, .ResAccordionHead, ' + '[class*="AccordionHead"], [class*="ContainerHeadBox"]' ); } function installiereAutomatischenSeitenscan() { if (document.documentElement.dataset.hasaSeitenscan === '1') return; document.documentElement.dataset.hasaSeitenscan = '1'; document.addEventListener('click', function (event) { if (!istHorizonKlappkopf(event.target)) return; // Horizon klappt den Inhalt erst nach dem Klick auf. Zwei kurze // Nachscans erfassen sowohl sofort vorhandene als auch verzögert // eingesetzte Zeilen, ohne andere Horizon-Seiten abzurufen. planeTechTreeSeitenscan(250); setTimeout(function () { planeTechTreeSeitenscan(150); }, 650); }, true); } function istAscensionSeite() { return document.querySelector('.AscAccCategory') !== null; } function starteSicher() { if (HAA_START_GELAUFEN) { return; } HAA_START_GELAUFEN = true; migriereAlteForschungsUeberwachung(); let versuche = 0; const timer = setInterval(function () { versuche++; if (document.body || versuche >= 8) { baueBox(); clearInterval(timer); } }, 400); } installiereAutomatischenSeitenscan(); installiereAudioFreigabe(); window.addEventListener('load', starteSicher); setTimeout(starteSicher, 1000); setInterval(aktivierePlanlistenKlappung, 700); setInterval(aktivierePlanStatusSticky, 700); })(); /* HASA 1.1 – Forschungsmodul komplett - STAN-Daten als maßgebliche Quelle für TechTree-Typen - allgemeine Erkennung von Gebäude, Forschung, Schiff und Verteidigung - Forschungs-Iststand wird aus der sichtbaren Horizon-Seite übernommen - Ascension-Reset verhindert veraltete Forschungs- und Gebäudestände - TechTree-Soll/Ist-Vergleich wird nach erneutem Seitenscan aktualisiert - responsive HASA-Breite für kleinere Displays - keine Horizon-Seiten werden im Hintergrund abgerufen */