Files
xxx-sphere-web/bin/main/static/games/vanilla/neuvanilla.html
Mario e2a71ab096
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Hashtags eingeführt
2026-04-11 01:14:33 +02:00

1344 lines
77 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vanilla Game Session einrichten xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Accordion ── */
.acc-item { margin-bottom: 0.75rem; }
.acc-header {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 1rem 1.25rem;
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 10px; cursor: pointer;
font-size: 0.88rem; font-weight: 600; color: var(--color-text);
text-transform: uppercase; letter-spacing: 0.05em; text-align: left;
transition: border-color 0.15s;
}
.acc-header:hover { border-color: var(--color-primary); background: var(--color-card); }
.acc-header.is-open { border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-color: var(--color-primary); }
.acc-chevron { transition: transform 0.2s; font-size: 0.8rem; }
.acc-header.is-open .acc-chevron { transform: rotate(180deg); }
.acc-body { display: none; border: 1px solid var(--color-primary); border-top: none; border-radius: 0 0 10px 10px; padding: 1.25rem; }
.acc-body.is-open { display: block; }
/* ── Settings ── */
.setting-row { margin-bottom: 1.25rem; }
.setting-row:last-child { margin-bottom: 0; }
.setting-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 0.4rem; }
.setting-header label { font-size: 0.85rem; color: #aaa; margin: 0; }
.setting-value { font-size: 1rem; font-weight: 600; color: var(--color-primary); min-width: 3.5rem; text-align: right; }
input[type="range"] { width: 100%; accent-color: var(--color-primary); cursor: pointer; }
input[type="range"]:disabled { opacity: 0.45; cursor: default; }
.settings-divider { border: none; border-top: 1px solid var(--color-secondary); margin: 1.25rem 0; }
/* ── Player sub-accordions ── */
.player-acc-item { margin-bottom: 0.5rem; }
.player-acc-header {
display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 1rem; background: var(--color-secondary);
border: 1px solid transparent; border-radius: 8px; cursor: pointer; transition: border-color 0.15s;
}
.player-acc-header:not([onclick]) { cursor: default; }
.player-acc-header:not([onclick]):hover { border-color: transparent; }
.player-acc-header:hover { border-color: var(--color-primary); }
.player-acc-header.is-open { border-color: var(--color-primary); border-radius: 8px 8px 0 0; }
.player-acc-body { display: none; border: 1px solid var(--color-primary); border-top: none; border-radius: 0 0 8px 8px; padding: 1.25rem; }
.player-acc-body.is-open { display: block; }
.player-acc-chevron { margin-left: auto; transition: transform 0.2s; font-size: 0.75rem; flex-shrink: 0; }
.player-acc-header.is-open .player-acc-chevron { transform: rotate(180deg); }
.player-title { font-weight: 600; color: var(--color-text); font-size: 0.93rem; white-space: nowrap; }
.player-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.player-avatar-initials { width: 28px; height: 28px; border-radius: 50%; background: var(--color-secondary); border: 1px solid var(--color-primary); display: flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: 700; color: var(--color-primary); flex-shrink: 0; text-transform: uppercase; user-select: none; }
.player-badge { background: var(--color-primary); color: #fff; font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; font-weight: 600; white-space: nowrap; }
.player-badge-pending { background: var(--color-secondary); color: var(--color-muted); font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; font-weight: 600; border: 1px solid var(--color-secondary); white-space: nowrap; }
.player-badge-accepted { background: #1a5c2a; color: #6fcf97; font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; font-weight: 600; white-space: nowrap; }
.player-remove { background: transparent; border: 1px solid var(--color-secondary); color: var(--color-muted); padding: 0.2rem 0.45rem; font-size: 0.72rem; border-radius: 4px; cursor: pointer; transition: border-color 0.15s, color 0.15s; flex-shrink: 0; }
.player-remove:hover { border-color: var(--color-primary); color: var(--color-primary); background: transparent; }
.btn-invite { background: transparent; border: 1px solid var(--color-primary); color: var(--color-primary); padding: 0.2rem 0.5rem; font-size: 0.72rem; font-weight: 600; border-radius: 4px; cursor: pointer; transition: background 0.15s, color 0.15s; flex-shrink: 0; }
.btn-invite:hover { background: var(--color-primary); color: #fff; }
.btn-cancel-invite { background: transparent; border: 1px solid var(--color-secondary); color: var(--color-muted); padding: 0.2rem 0.6rem; font-size: 0.75rem; border-radius: 4px; cursor: pointer; }
.btn-cancel-invite:hover { border-color: var(--color-primary); color: var(--color-primary); background: transparent; }
/* ── Player form ── */
.card-field { margin-bottom: 1rem; }
.card-field:last-child { margin-bottom: 0; }
.card-field > label { font-size: 0.8rem; color: #aaa; margin: 0 0 0.5rem 0; display: block; }
.check-group { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.check-group--two-col { display: grid; grid-template-columns: 1fr 1fr; }
.check-item { display: inline-flex; align-items: flex-start; gap: 0.45rem; background: var(--color-secondary); border: 1px solid transparent; border-radius: 6px; padding: 0.4rem 0.7rem; cursor: pointer; transition: border-color 0.15s; user-select: none; }
.check-item.is-checked { border-color: var(--color-primary); }
.check-item.is-disabled { opacity: 0.5; pointer-events: none; cursor: default; }
.check-item input { accent-color: var(--color-primary); width: auto; margin-top: 0.15rem; cursor: pointer; flex-shrink: 0; }
.check-item-label { font-size: 0.88rem; color: var(--color-text); line-height: 1.3; }
.check-item-desc { display: block; font-size: 0.72rem; color: var(--color-muted); margin-top: 0.1rem; }
.field-error { font-size: 0.78rem; color: var(--color-primary); margin-top: 0.3rem; display: none; }
.add-player-btn { width: 100%; background: transparent; border: 1px dashed var(--color-secondary); color: var(--color-muted); padding: 0.7rem; border-radius: 8px; font-size: 0.88rem; font-weight: normal; cursor: pointer; transition: border-color 0.15s, color 0.15s; margin-top: 0.5rem; }
.add-player-btn:hover { border-color: var(--color-primary); color: var(--color-text); background: transparent; }
.pending-info { text-align: center; color: var(--color-muted); font-size: 0.9rem; padding: 0.75rem 0; }
.pending-name { font-weight: 600; color: var(--color-text); font-size: 1rem; margin-bottom: 0.2rem; }
/* ── Gruppe / Toy items ── */
.select-all-label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none; font-size: 0.78rem; color: var(--color-muted); margin-bottom: 0.5rem; }
.select-all-label input { accent-color: var(--color-primary); width: 14px; height: 14px; cursor: pointer; flex-shrink: 0; }
.gruppe-list { list-style: none; padding: 0; margin: 0 0 1.25rem 0; }
.gruppe-list:last-child { margin-bottom: 0; }
.gruppe-item, .toy-item {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.6rem 0.85rem; border-radius: 8px;
background: var(--color-card); border: 1px solid var(--color-secondary);
margin-bottom: 0.5rem; cursor: pointer; transition: border-color 0.15s; user-select: none;
}
.gruppe-item.is-checked, .toy-item.is-checked { border-color: var(--color-primary); }
.gruppe-item input, .toy-item input { accent-color: var(--color-primary); flex-shrink: 0; width: 14px; height: 14px; cursor: pointer; }
.gruppe-item span, .toy-item span { flex: 1; min-width: 0; }
.item-img { width: 38px; height: 38px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.gruppe-item-name, .toy-item-name { font-size: 0.95rem; font-weight: 600; color: var(--color-text); }
.gruppe-item-desc, .toy-item-desc { display: block; font-size: 0.8rem; color: var(--color-muted); margin-top: 0.15rem; }
.empty-hint { color: var(--color-muted); font-size: 0.875rem; font-style: italic; padding: 0.5rem 0; }
.aufgaben-section-label { font-size: 0.78rem; font-weight: 600; color: var(--color-muted); text-transform: uppercase; letter-spacing: 0.04em; margin: 1rem 0 0.5rem 0; }
.aufgaben-section-label:first-child { margin-top: 0; }
.toys-hint { font-size: 0.85rem; color: var(--color-muted); margin-bottom: 1rem; }
.toys-badge { font-size: 0.75rem; font-weight: 600; background: var(--color-primary); color: #fff; border-radius: 999px; padding: 0.1em 0.55em; margin-left: 0.5rem; vertical-align: middle; }
/* ── Guest hint ── */
.guest-hint { font-size: 0.8rem; color: var(--color-muted); font-style: italic; margin-bottom: 0.75rem; padding: 0.5rem 0.75rem; background: var(--color-secondary); border-radius: 6px; }
/* ── Wait view ── */
.wait-card { text-align: center; padding: 3rem 1rem; }
.wait-icon { font-size: 3rem; margin-bottom: 1.5rem; animation: pulse 2s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
.wait-title { font-size: 1.3rem; font-weight: 700; margin-bottom: 0.75rem; }
.wait-sub { font-size: 0.9rem; color: var(--color-muted); line-height: 1.6; margin-bottom: 2rem; }
/* ── Modals ── */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 1.5rem; }
.modal-card { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 14px; padding: 2rem; max-width: 420px; width: 100%; }
.modal-title { font-size: 1.1rem; font-weight: 700; color: var(--color-text); margin-bottom: 0.75rem; }
.modal-text { font-size: 0.9rem; color: var(--color-muted); line-height: 1.6; margin-bottom: 1.5rem; }
.modal-actions { display: flex; flex-direction: column; gap: 0.6rem; }
.modal-actions button { width: 100%; padding: 0.75rem; }
.friend-combobox { position: relative; }
.friend-dropdown { display: none; position: absolute; top: 100%; left: 0; right: 0; background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 8px; max-height: 220px; overflow-y: auto; z-index: 10; margin-top: 0.25rem; }
.friend-dropdown-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.75rem; cursor: pointer; transition: background 0.1s; font-size: 0.9rem; font-weight: 600; }
.friend-dropdown-item:hover { background: var(--color-secondary); }
.selected-friend-box { display: none; margin-top: 0.75rem; padding: 0.6rem 0.75rem; background: var(--color-secondary); border-radius: 8px; font-size: 0.9rem; font-weight: 600; border: 1px solid var(--color-primary); color: var(--color-text); }
.friend-avatar { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; background: var(--color-secondary); flex-shrink: 0; }
</style>
</head>
<body class="app">
<div class="modal-overlay" id="sessionModal" style="display:none;">
<div class="modal-card">
<div class="modal-title" id="sessionModalTitle"></div>
<div class="modal-text" id="sessionModalText"></div>
<div class="modal-actions" id="sessionModalActions"></div>
</div>
</div>
<div class="modal-overlay" id="errorModal" style="display:none;">
<div class="modal-card" style="text-align:center;">
<div style="font-size:1.5rem;margin-bottom:0.75rem;">⚠️</div>
<div class="modal-title" id="errorModalTitle"></div>
<div style="font-size:0.9rem;color:var(--color-muted);line-height:1.5;margin-bottom:1.25rem;" id="errorModalText"></div>
<button onclick="document.getElementById('errorModal').style.display='none'">OK</button>
</div>
</div>
<div class="modal-overlay" id="friendModal" style="display:none;">
<div class="modal-card">
<div class="modal-title">Freund einladen</div>
<div class="friend-combobox">
<input type="text" id="friendSearch" placeholder="Name eingeben…" autocomplete="off" oninput="filterFreunde(this.value)">
<div class="friend-dropdown" id="friendDropdown"></div>
</div>
<div class="selected-friend-box" id="selectedFriendBox"></div>
<button id="btnEinladen" style="margin-top:1rem;width:100%;" disabled onclick="confirmedEinladen()">Einladen</button>
<button class="secondary" style="margin-top:0.6rem;width:100%;" onclick="schliesseFriendModal()">Abbrechen</button>
</div>
</div>
<div class="main" id="setupView" style="display:none;">
<div class="content">
<h1>Vanilla Game Session einrichten</h1>
<p id="pageSubtitle" style="margin-bottom:1.5rem;">Session einrichten</p>
<!-- Accordion 1: Grundeinstellungen -->
<div class="acc-item">
<button class="acc-header is-open" id="acc-grundeinstellungen-btn" onclick="toggleAcc('grundeinstellungen')">
<span>Grundeinstellungen</span><span class="acc-chevron"></span>
</button>
<div class="acc-body is-open" id="acc-grundeinstellungen-body">
<div id="guestSettingsHint" class="guest-hint" style="display:none;">Einstellungen werden vom Host festgelegt nur zur Ansicht.</div>
<div class="setting-row">
<div class="setting-header"><label for="sldSpieldauer">Spieldauer</label><span class="setting-value" id="valSpieldauer">Mittel</span></div>
<input type="range" id="sldSpieldauer" min="0" max="4" value="2" oninput="updateSpieldauer(this.value)">
</div>
<hr class="settings-divider">
<div style="font-size:0.78rem;font-weight:600;color:var(--color-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:0.75rem;">Mitspieler</div>
<div id="playersContainer"></div>
<button class="add-player-btn" id="btnAddPlayer" onclick="addPlayer()">+ Spieler hinzufügen</button>
</div>
</div>
<!-- Accordion 2: Aufgaben -->
<div class="acc-item">
<button class="acc-header" id="acc-aufgaben-btn" onclick="toggleAcc('aufgaben')">
<span>Aufgaben</span><span class="acc-chevron"></span>
</button>
<div class="acc-body" id="acc-aufgaben-body">
<div id="guestAufgabenHint" class="guest-hint" style="display:none;">Aufgaben werden vom Host festgelegt nur zur Ansicht.</div>
<p style="font-size:0.85rem;color:var(--color-muted);margin-bottom:0.75rem;">
Gruppen verwalten: <a href="/games/vanilla/aufgaben.html" style="color:var(--color-primary);">Aufgaben-Verwaltung (Vanilla)</a>
</p>
<div id="sectionOwn">
<div class="aufgaben-section-label"><label class="select-all-label"><input type="checkbox" class="select-all-cb" data-list="listOwn"> Eigene Gruppen</label></div>
<ul class="gruppe-list" id="listOwn"><li class="empty-hint">Wird geladen…</li></ul>
</div>
<div id="sectionSubscribed">
<div class="aufgaben-section-label"><label class="select-all-label"><input type="checkbox" class="select-all-cb" data-list="listSubscribed"> Abonnierte Gruppen</label></div>
<ul class="gruppe-list" id="listSubscribed"><li class="empty-hint">Wird geladen…</li></ul>
</div>
<div id="sectionSystem">
<div class="aufgaben-section-label"><label class="select-all-label"><input type="checkbox" class="select-all-cb" data-list="listSystem"> System-Gruppen</label></div>
<ul class="gruppe-list" id="listSystem"><li class="empty-hint">Wird geladen…</li></ul>
</div>
</div>
</div>
<!-- Accordion 4: Toys -->
<div class="acc-item">
<button class="acc-header" id="acc-toys-btn" onclick="toggleAcc('toys')">
<span>Toys<span id="toys-badge" class="toys-badge" style="display:none;"></span></span><span class="acc-chevron"></span>
</button>
<div class="acc-body" id="acc-toys-body">
<div id="guestToysHint" class="guest-hint" style="display:none;">Toys werden vom Host festgelegt nur zur Ansicht.</div>
<p class="toys-hint">Deaktiviere Toys, die nicht zur Verfügung stehen. Aufgaben, die diese benötigen, werden nicht gespielt.</p>
<div id="toyList"><p class="empty-hint">Bitte zuerst Aufgaben-Gruppen auswählen.</p></div>
</div>
</div>
<div class="message" id="message" style="margin-top:1rem;display:none;"></div>
<button class="full-width" id="btnAction" onclick="handleAction()" style="margin-top:1rem;">Spiel starten</button>
</div>
</div>
<div class="main" id="waitView" style="display:none;">
<div class="content wait-card">
<div class="wait-icon"></div>
<div class="wait-title">Warte auf Spielstart…</div>
<div class="wait-sub">Der Host startet das Spiel in Kürze. Diese Seite aktualisiert sich automatisch.</div>
<button class="secondary full-width" onclick="abbrechenGast()">Abbrechen</button>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
// ── Constants ──
const SPIELDAUER = [
{ label: 'Sehr kurz', aufgaben: 4, zeitfaktor: 0.5 },
{ label: 'Kurz', aufgaben: 6, zeitfaktor: 0.7 },
{ label: 'Mittel', aufgaben: 8, zeitfaktor: 1.0 },
{ label: 'Lang', aufgaben: 10, zeitfaktor: 1.5 },
{ label: 'Sehr lang', aufgaben: 14, zeitfaktor: 2.0 },
];
const GESCHLECHTER = [
{ value: 'MAENNLICH', label: 'Männlich' },
{ value: 'WEIBLICH', label: 'Weiblich' },
{ value: 'DIVERS', label: 'Divers' },
];
const ROLLEN = [
{ value: 'AUFGABE_AKTIV', label: 'Aufgabe Aktiv' },
{ value: 'AUFGABE_PASSIV', label: 'Aufgabe Passiv' },
{ value: 'BESTRAFUNG_AKTIV', label: 'Bestrafung Aktiv' },
{ value: 'BESTRAFUNG_PASSIV', label: 'Bestrafung Passiv' },
];
const WERKZEUGE_DEFAULTS = {
MAENNLICH: ['MUND','PENIS','ANUS','UMSCHNALLDILDO'],
WEIBLICH: ['MUND','VAGINA','ANUS','UMSCHNALLDILDO'],
DIVERS: ['MUND','ANUS','UMSCHNALLDILDO'],
};
const WERKZEUGE = [
{ value: 'MUND', label: 'Mund', desc: 'Gewillt den Mund einzusetzen' },
{ value: 'VAGINA', label: 'Vagina', desc: 'Verfügt über eine Vagina und setzt sie ein' },
{ value: 'PENIS', label: 'Penis', desc: 'Verfügt über einen Penis und setzt ihn ein' },
{ value: 'ANUS', label: 'Anus', desc: 'Gewillt den Anus einzusetzen' },
{ value: 'UMSCHNALLDILDO', label: 'Umschnall-Dildo', desc: 'Verfügt über einen Umschnall-Dildo' },
];
const ROLE_LABELS = {
AUFGABE_AKTIV: 'Aufgabe Aktiv', AUFGABE_PASSIV: 'Aufgabe Passiv',
BESTRAFUNG_AKTIV: 'Bestrafung Aktiv', BESTRAFUNG_PASSIV: 'Bestrafung Passiv',
};
const GESCHLECHT_LABEL = { WEIBLICH: 'Weiblich', DIVERS: 'Divers', MAENNLICH: 'Männlich' };
const VANILLA_STORAGE_KEYS = [
'vanilla-session-id','vanilla-session-settings','vanilla-session-setup',
'vanilla-session-gruppen','vanilla-session-toys','vanilla-session-game',
];
const ACC_IDS = ['grundeinstellungen','aufgaben','toys'];
const MAX_PLAYERS = 2;
// ── State ──
let isGuestMode = false;
let guestEinladungId = null;
let guestOwnPlayerId = null;
let guestPollInterval = null;
let playerSeq = 0;
let playerIds = [];
let playerInvitations = {};
let playerProfilePics = {};
let pollIntervalId = null;
let myUserId = null;
let selfPlayerId = null;
let freundeListe = [];
let setupId = null;
let savedGruppen = new Set();
let gruppenContent = null;
let loadedForGruppen = null;
let warnungsAkzeptiert = false;
const savedToysRaw = sessionStorage.getItem('vanilla-session-toys');
let savedToyIds = savedToysRaw ? new Set(JSON.parse(savedToysRaw).map(t => t.toyId)) : null;
let toysNeedReload = true;
// ── Accordion ──
function toggleAcc(id) {
const wasOpen = document.getElementById(`acc-${id}-btn`).classList.contains('is-open');
ACC_IDS.forEach(accId => {
const btn = document.getElementById(`acc-${accId}-btn`);
const body = document.getElementById(`acc-${accId}-body`);
const open = accId === id && !wasOpen;
btn.classList.toggle('is-open', open);
body.classList.toggle('is-open', open);
});
if (id === 'toys' && !wasOpen && toysNeedReload) ladeToys();
}
function togglePlayerAcc(id) {
if (isGuestMode && id !== guestOwnPlayerId) return;
if (playerIds.indexOf(id) === 1) return; // zweiter Spieler bleibt immer offen
playerIds.forEach(pid => {
const btn = document.getElementById(`pacc-${pid}-btn`);
const body = document.getElementById(`pacc-${pid}-body`);
if (!btn || !body) return;
if (playerIds.indexOf(pid) === 1) return; // zweiter Spieler nie anfassen
const open = pid === id && !btn.classList.contains('is-open');
btn.classList.toggle('is-open', open);
body.classList.toggle('is-open', open);
});
}
function openPlayerAcc(id) {
playerIds.forEach(pid => {
const btn = document.getElementById(`pacc-${pid}-btn`);
const body = document.getElementById(`pacc-${pid}-body`);
if (!btn || !body) return;
if (playerIds.indexOf(pid) === 1) return; // zweiter Spieler bleibt immer offen
btn.classList.toggle('is-open', pid === id);
body.classList.toggle('is-open', pid === id);
});
}
// ── Session settings ──
function updateSpieldauer(val) {
document.getElementById('valSpieldauer').textContent = SPIELDAUER[val].label;
}
function applySettings(s) {
function setSlider(id, displayId, value, transform) {
const el = document.getElementById(id); if (!el) return;
el.value = value;
document.getElementById(displayId).textContent = transform ? transform(value) : value;
}
if (s.aufgabenProLevel != null) {
const idx = SPIELDAUER.reduce((best, d, i) =>
Math.abs(d.aufgaben - s.aufgabenProLevel) < Math.abs(SPIELDAUER[best].aufgaben - s.aufgabenProLevel) ? i : best, 2);
setSlider('sldSpieldauer', 'valSpieldauer', idx, v => SPIELDAUER[v].label);
}
}
function disableSettingsForGuest() {
['sldSpieldauer'].forEach(id => { const el = document.getElementById(id); if (el) el.disabled = true; });
document.getElementById('guestSettingsHint').style.display = 'block';
document.getElementById('guestAufgabenHint').style.display = 'block';
document.getElementById('guestToysHint').style.display = 'block';
}
// ── Player builders ──
function buildCheckItems(name, items, type, disabled = false) {
return items.map(({ value, label, desc }) => `
<label class="check-item${disabled ? ' is-disabled' : ''}">
<input type="${type}" name="${name}" value="${value}"${disabled ? ' disabled' : ''}>
<span><span class="check-item-label">${label}</span>${desc ? `<span class="check-item-desc">${desc}</span>` : ''}</span>
</label>`).join('');
}
function buildPlayerBody(id, nameValue, nameReadOnly = false, genderDisabled = false, allDisabled = false) {
const nameHtml = nameReadOnly
? `<input type="text" id="p${id}-name" value="${nameValue}" readonly style="background:transparent;cursor:default;color:var(--color-muted);">`
: `<input type="text" id="p${id}-name" value="${nameValue}" placeholder="Name" autocomplete="off">`;
return `
<div class="card-field">
<label>Name</label>${nameHtml}
<div class="field-error" id="p${id}-name-err">Bitte Namen eingeben.</div>
</div>
<div class="card-field">
<label>Verfügbar</label>
<div class="check-group check-group--two-col">${buildCheckItems('p'+id+'-werkzeuge', WERKZEUGE, 'checkbox', allDisabled)}</div>
<div class="field-error" id="p${id}-werkzeuge-err">Bitte mindestens ein Werkzeug wählen.</div>
<div id="p${id}-chastity-hint" style="display:none;font-size:0.78rem;color:var(--color-muted);margin-top:0.4rem;font-style:italic;line-height:1.4;"></div>
</div>`;
}
function buildAvatarHtml(id, name) {
const pic = playerProfilePics[id];
if (pic) {
return `<img class="player-avatar" src="data:image/png;base64,${pic}" alt="">`;
}
const initial = (name || '?').trim().charAt(0);
return `<span class="player-avatar-initials">${initial}</span>`;
}
function buildPlayerAccHtml(id, nameValue, isSelf, nameReadOnly = false, genderDisabled = false, allDisabled = false) {
const num = playerIds.indexOf(id) + 1;
const isSecond = num === 2;
const selfBadge = isSelf ? '<span class="player-badge">Du</span>' : '';
const inviteBtn = (!isSelf && !allDisabled)
? `<button class="btn-invite" onclick="oeffneFreundeModal(${id});event.stopPropagation()">👥 Einladen</button>` : '';
const removeBtn = !allDisabled
? `<button class="player-remove" onclick="removePlayer(${id});event.stopPropagation()">✕</button>` : '';
const nameLabel = nameValue ? `: ${nameValue}` : '';
const openCls = isSecond ? ' is-open' : '';
const chevron = isSecond ? '' : '<span class="player-acc-chevron">▾</span>';
const clickAttr = isSecond ? '' : ` onclick="togglePlayerAcc(${id})"`;
return `
<div class="player-acc-item" id="player-${id}">
<div class="player-acc-header${openCls}" id="pacc-${id}-btn"${clickAttr}>
<span id="pacc-${id}-avatar">${buildAvatarHtml(id, nameValue)}</span>
<span class="player-title">Spieler ${num}<span id="pacc-${id}-namelabel">${nameLabel}</span></span>
${selfBadge}<span id="pacc-${id}-badge"></span>
${inviteBtn}${removeBtn}
${chevron}
</div>
<div class="player-acc-body${openCls}" id="pacc-${id}-body">
<div id="p${id}-body">${buildPlayerBody(id, nameValue, nameReadOnly, genderDisabled, allDisabled)}</div>
</div>
</div>`;
}
function addPlayer(nameValue = '', isSelf = false, nameReadOnly = false, genderDisabled = false, allDisabled = false) {
if (playerIds.length >= MAX_PLAYERS) return null;
playerSeq++;
const id = playerSeq;
if (isSelf) selfPlayerId = id;
playerIds.push(id);
playerInvitations[id] = null;
playerProfilePics[id] = null;
document.getElementById('playersContainer').insertAdjacentHTML('beforeend',
buildPlayerAccHtml(id, nameValue, isSelf, nameReadOnly, genderDisabled, allDisabled));
refreshRemoveButtons();
updateAddPlayerButton();
return id;
}
function updateAddPlayerButton() {
const btn = document.getElementById('btnAddPlayer');
if (btn) btn.style.display = playerIds.length >= MAX_PLAYERS ? 'none' : '';
}
function removePlayer(id) {
const inv = playerInvitations[id];
if (inv && ['PENDING','ACCEPTED_OWN','ACCEPTED_HOST'].includes(inv.status))
fetch(`/vanilla/einladung/${inv.einladungId}`, { method: 'DELETE' }).catch(() => {});
playerInvitations[id] = null;
if (playerIds.length <= 2) {
document.getElementById(`pacc-${id}-badge`).innerHTML = '';
const invBtn = document.querySelector(`#player-${id} .btn-invite`);
if (invBtn) invBtn.style.display = '';
document.getElementById(`p${id}-body`).innerHTML = buildPlayerBody(id, '', false, false, false);
updateAddPlayerButton();
return;
}
document.getElementById('player-'+id)?.remove();
playerIds = playerIds.filter(x => x !== id);
delete playerInvitations[id];
delete playerProfilePics[id];
refreshPlayerTitles(); refreshRemoveButtons(); updateAddPlayerButton();
}
function updatePlayerHeader(id, name) {
const avatarEl = document.getElementById(`pacc-${id}-avatar`);
const nameLabel = document.getElementById(`pacc-${id}-namelabel`);
if (avatarEl) avatarEl.innerHTML = buildAvatarHtml(id, name);
if (nameLabel) nameLabel.textContent = name ? `: ${name}` : '';
}
function refreshPlayerTitles() {
playerIds.forEach((id, idx) => {
const titleEl = document.querySelector(`#player-${id} .player-title`);
if (!titleEl) return;
const nameLabel = document.getElementById(`pacc-${id}-namelabel`);
const currentName = nameLabel ? nameLabel.textContent.replace(/^: /, '') : '';
titleEl.innerHTML = `Spieler ${idx + 1}<span id="pacc-${id}-namelabel">${currentName ? ': ' + currentName : ''}</span>`;
});
}
function refreshRemoveButtons() {
playerIds.forEach((id, idx) => {
const btn = document.querySelector(`#player-${id} .player-remove`);
if (btn) btn.style.display = idx === 0 ? 'none' : '';
});
}
document.addEventListener('change', e => {
const input = e.target;
// Select-all checkbox for gruppe sections
if (input.classList.contains('select-all-cb')) {
const list = document.getElementById(input.dataset.list);
list.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = input.checked;
cb.closest('.gruppe-item')?.classList.toggle('is-checked', cb.checked);
});
onGruppenChanged(); return;
}
// Gruppe item checkbox
if (input.closest('.gruppe-item')) {
input.closest('.gruppe-item')?.classList.toggle('is-checked', input.checked);
updateSelectAll(input.closest('.gruppe-list'));
onGruppenChanged(); return;
}
// Toy checkbox
if (input.closest('.toy-item')) {
input.closest('.toy-item')?.classList.toggle('is-checked', input.checked);
warnungsAkzeptiert = false; hideMessage(); return;
}
// Player checkboxes
if (input.type !== 'checkbox' && input.type !== 'radio') return;
if (input.type === 'radio') {
document.querySelectorAll(`input[name="${input.name}"]`).forEach(r =>
r.closest('.check-item')?.classList.toggle('is-checked', r.checked));
} else {
const lbl = input.closest('.check-item');
if (lbl?.dataset.chastitylocked) {
input.checked = false; lbl.classList.remove('is-checked');
const card = lbl.closest('[id^="player-"]');
if (card) flashChastityHint(card.id.replace('player-', ''));
return;
}
lbl?.classList.toggle('is-checked', input.checked);
}
});
function onGruppenChanged() {
warnungsAkzeptiert = false; hideMessage();
gruppenContent = null; loadedForGruppen = null; toysNeedReload = true;
const badge = document.getElementById('toys-badge');
badge.style.display = 'none'; badge.textContent = '';
if (document.getElementById('acc-toys-body').classList.contains('is-open')) ladeToys();
}
function updateSelectAll(list) {
if (!list) return;
const items = [...list.querySelectorAll('input[type="checkbox"]')];
if (!items.length) return;
const section = list.closest('[id^="section"]');
const cb = section?.querySelector('.select-all-cb'); if (!cb) return;
const n = items.filter(c => c.checked).length;
cb.checked = n === items.length; cb.indeterminate = n > 0 && n < items.length;
}
function getChecked(name) {
return [...document.querySelectorAll(`input[name="${name}"]:checked`)].map(el => el.value);
}
function getSelectedGruppen() {
return [...document.querySelectorAll('.gruppe-list input[type="checkbox"]:checked')].map(cb => cb.value);
}
function setFieldError(id, show) {
const el = document.getElementById(id); if (el) el.style.display = show ? 'block' : 'none';
}
// ── Gruppe lists ──
function renderGruppeList(containerId, gruppen) {
const ul = document.getElementById(containerId);
const section = ul.closest('[id^="section"]');
const selectAllWrap = section?.querySelector('.select-all-label');
const filtered = gruppen.filter(g =>
(g.aufgaben || []).length > 0 || (g.finisher || []).length > 0
);
if (!filtered.length) {
ul.innerHTML = '<li class="empty-hint">Keine Gruppen vorhanden.</li>';
if (selectAllWrap) selectAllWrap.style.visibility = 'hidden'; return;
}
ul.innerHTML = filtered.map(g => {
const checked = savedGruppen.has(g.gruppenId);
return `<li><label class="gruppe-item${checked ? ' is-checked' : ''}">
<input type="checkbox" value="${g.gruppenId}"${checked ? ' checked' : ''}>
<span><span class="gruppe-item-name">${g.name}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span>
${g.bild ? `<img class="item-img" src="data:image/png;base64,${g.bild}" alt="">` : ''}
</label></li>`;
}).join('');
updateSelectAll(ul);
}
// ── Toys ──
async function ladeGruppenContent() {
const selected = getSelectedGruppen();
const key = JSON.stringify([...selected].sort());
if (loadedForGruppen === key && gruppenContent) return gruppenContent;
if (!selected.length) {
gruppenContent = { aufgaben: [], finisher: [] };
loadedForGruppen = key; return gruppenContent;
}
const gruppen = await Promise.all(selected.map(id => fetch(`/vanilla/gruppe/${id}`).then(r => r.ok ? r.json() : null)));
gruppenContent = { aufgaben: [], finisher: [] };
gruppen.filter(Boolean).forEach(g => {
gruppenContent.aufgaben.push(...(g.aufgaben || []));
gruppenContent.finisher.push(...(g.finisher || []));
});
loadedForGruppen = key; toysNeedReload = false;
return gruppenContent;
}
async function ladeToys() {
const toyList = document.getElementById('toyList');
const selected = getSelectedGruppen();
if (!selected.length) {
toyList.innerHTML = '<p class="empty-hint">Bitte zuerst Aufgaben-Gruppen auswählen.</p>';
toysNeedReload = false; return;
}
toyList.innerHTML = '<p class="empty-hint">Lade…</p>';
try {
const params = selected.map(id => `gruppenIds=${encodeURIComponent(id)}`).join('&');
const res = await fetch(`/vanilla/toy/required?${params}`);
if (!res.ok) throw new Error();
const toys = await res.json();
toysNeedReload = false;
const badge = document.getElementById('toys-badge');
if (toys.length) { badge.textContent = toys.length; badge.style.display = ''; }
else { badge.style.display = 'none'; badge.textContent = ''; }
if (!toys.length) {
toyList.innerHTML = '<p class="empty-hint">Keine Toys erforderlich alle Aufgaben können gespielt werden.</p>'; return;
}
toyList.innerHTML = toys.map(toy => {
const checked = savedToyIds === null || savedToyIds.has(toy.toyId);
return `<label class="toy-item${checked ? ' is-checked' : ''}">
<input type="checkbox" value="${toy.toyId}"${checked ? ' checked' : ''}>
<span><span class="toy-item-name">${toy.name}</span>${toy.beschreibung ? `<span class="toy-item-desc">${toy.beschreibung}</span>` : ''}</span>
${toy.bild ? `<img class="item-img" src="data:image/png;base64,${toy.bild}" alt="">` : ''}
</label>`;
}).join('');
} catch (_) {
toyList.innerHTML = '<p class="empty-hint">Fehler beim Laden der Toys.</p>';
}
}
function validateContent(content, mitspieler) {
const errors = [], warnings = [];
const aufgabenByLevel = {};
content.aufgaben.forEach(a => { const l = a.level ?? 0; aufgabenByLevel[l] = (aufgabenByLevel[l] || 0) + 1; });
for (const [level, count] of Object.entries(aufgabenByLevel)) {
if (count < 5) errors.push(`Level ${level}: Nur ${count} Aufgabe(n) Minimum 5 erforderlich`);
else if (count < 10) warnings.push(`Level ${level}: Nur ${count} Aufgaben empfohlen ≥ 10`);
}
const beteiligtGeschlecht = [...new Set((mitspieler || []).map(p => p.geschlecht).filter(Boolean))];
for (const g of beteiligtGeschlecht) {
const count = (content.finisher || []).filter(f => f.geschlecht === g).length;
if (count < 1) errors.push(`Kein Finisher für ${GESCHLECHT_LABEL[g] || g} vorhanden`);
}
return { errors, warnings };
}
function showValidation(errors, warnings, mitHinweis) {
const el = document.getElementById('message');
el.innerHTML = [
...errors.map(e => `<div>✕ ${e}</div>`),
...warnings.map(w => `<div>⚠ ${w}</div>`),
...(mitHinweis ? ['<div style="margin-top:0.5rem;font-style:italic;">Nochmals auf Spiel starten klicken um fortzufahren.</div>'] : []),
].join('');
el.className = `message ${errors.length ? 'error' : 'warning'}`; el.style.display = 'block';
}
// ── Invitation rendering ──
function renderPending(id) {
const inv = playerInvitations[id];
const badge = document.getElementById(`pacc-${id}-badge`);
const body = document.getElementById(`p${id}-body`);
const header = document.getElementById(`pacc-${id}-btn`);
const invBtn = header?.querySelector('.btn-invite');
if (!inv) {
if (badge) badge.innerHTML = '';
if (invBtn) invBtn.style.display = '';
if (body) body.innerHTML = buildPlayerBody(id, '', false, false, false);
playerProfilePics[id] = null;
updatePlayerHeader(id, '');
return;
}
if (invBtn) invBtn.style.display = 'none';
if (inv.status === 'PENDING') {
if (badge) badge.innerHTML = '<span class="player-badge-pending">Ausstehend</span>';
if (body) body.innerHTML = `<div class="pending-info"><div class="pending-name">${inv.inviteeName}</div><div>Einladung gesendet warte auf Antwort…</div><button class="btn-cancel-invite" style="margin-top:1rem;" onclick="cancelEinladung(${id})">Einladung abbrechen</button></div>`;
updatePlayerHeader(id, inv.inviteeName);
} else if (inv.status === 'ACCEPTED_OWN') {
if (badge) badge.innerHTML = '<span class="player-badge-accepted">✓ Eigenes Gerät</span>';
if (body) body.innerHTML = `<div class="pending-info"><div class="pending-name">${inv.inviteeName}</div><div style="font-size:0.8rem;color:var(--color-muted);">Spieler konfiguriert Präferenzen auf dem eigenen Gerät.</div><button class="btn-cancel-invite" style="margin-top:1rem;" onclick="cancelEinladung(${id})">Einladung abbrechen</button></div>`;
updatePlayerHeader(id, inv.inviteeName);
} else if (inv.status === 'ACCEPTED_HOST') {
if (badge) badge.innerHTML = '<span class="player-badge-accepted">✓ Host-Gerät</span>';
const hasGeschlecht = inv.defaults && inv.defaults.geschlecht;
if (body) body.innerHTML = buildPlayerBody(id, inv.inviteeName, true, hasGeschlecht, false);
if (inv.defaults) restorePlayer(id, { geschlecht: inv.defaults.geschlecht, spieltMit: inv.defaults.spieltMit || [], rollen: inv.defaults.rollen || [], werkzeuge: inv.defaults.werkzeuge || [] });
pruefeChastityConstraint(id, inv.inviteeId);
updatePlayerHeader(id, inv.inviteeName);
} else if (inv.status === 'DECLINED' || inv.status === 'CANCELLED') {
if (badge) badge.innerHTML = '';
if (invBtn) invBtn.style.display = '';
playerInvitations[id] = null;
playerProfilePics[id] = null;
if (body) body.innerHTML = buildPlayerBody(id, '', false, false, false);
updatePlayerHeader(id, '');
}
}
async function cancelEinladung(id) {
const inv = playerInvitations[id]; if (!inv) return;
await fetch(`/vanilla/einladung/${inv.einladungId}`, { method: 'DELETE' }).catch(() => {});
playerInvitations[id] = null; renderPending(id);
}
// ── Freunde-Modal ──
let currentInvitePlayerId = null;
let selectedFriend = null;
async function oeffneFreundeModal(playerId) {
currentInvitePlayerId = playerId; selectedFriend = null;
document.getElementById('friendSearch').value = '';
document.getElementById('friendDropdown').style.display = 'none';
document.getElementById('friendDropdown').innerHTML = '';
document.getElementById('selectedFriendBox').style.display = 'none';
document.getElementById('btnEinladen').disabled = true;
document.getElementById('friendModal').style.display = 'flex';
if (freundeListe.length === 0) {
try { const r = await fetch('/social/friends'); freundeListe = r.ok ? await r.json() : []; } catch (_) { freundeListe = []; }
}
}
function filterFreunde(query) {
selectedFriend = null;
document.getElementById('selectedFriendBox').style.display = 'none';
document.getElementById('btnEinladen').disabled = true;
const dropdown = document.getElementById('friendDropdown'); dropdown.innerHTML = '';
const q = query.trim().toLowerCase();
if (!q) { dropdown.style.display = 'none'; return; }
const invitedIds = new Set(Object.values(playerInvitations)
.filter(inv => inv && ['PENDING','ACCEPTED_OWN','ACCEPTED_HOST'].includes(inv.status)).map(inv => inv.inviteeId));
const matches = freundeListe.filter(f => (f.user.name||'').toLowerCase().includes(q) && !invitedIds.has(f.user.userId));
if (!matches.length) {
dropdown.innerHTML = '<div style="padding:0.6rem 0.75rem;color:var(--color-muted);font-size:0.9rem;">Keine Treffer.</div>';
dropdown.style.display = 'block'; return;
}
matches.forEach(f => {
const item = document.createElement('div'); item.className = 'friend-dropdown-item';
item.addEventListener('click', () => selectFriend(f.user.userId, f.user.name || 'Unbekannt', f.user.profilePicture || null));
if (f.user.profilePicture) { const img = document.createElement('img'); img.className = 'friend-avatar'; img.src = 'data:image/png;base64,'+f.user.profilePicture; img.alt=''; item.appendChild(img); }
else { const av = document.createElement('div'); av.className = 'friend-avatar'; item.appendChild(av); }
const span = document.createElement('span'); span.textContent = f.user.name || 'Unbekannt'; item.appendChild(span);
dropdown.appendChild(item);
});
dropdown.style.display = 'block';
}
function selectFriend(userId, name, profilePicture = null) {
selectedFriend = { userId, name, profilePicture };
document.getElementById('friendSearch').value = name;
document.getElementById('friendDropdown').style.display = 'none';
const box = document.getElementById('selectedFriendBox'); box.textContent = '✓ '+name; box.style.display = 'block';
document.getElementById('btnEinladen').disabled = false;
}
async function confirmedEinladen() { if (selectedFriend) await einladen(selectedFriend.userId, selectedFriend.name, selectedFriend.profilePicture); }
function schliesseFriendModal() { document.getElementById('friendModal').style.display = 'none'; currentInvitePlayerId = null; selectedFriend = null; }
async function einladen(inviteeId, inviteeName, inviteePicture = null) {
const id = currentInvitePlayerId; schliesseFriendModal(); if (!id) return;
if (!setupId) {
if (!sessionStorage.getItem('vanilla-setup-id')) sessionStorage.setItem('vanilla-setup-id', crypto.randomUUID());
setupId = sessionStorage.getItem('vanilla-setup-id');
}
await ensureDraftExists();
try {
const res = await fetch('/vanilla/einladung', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setupId, slotIndex: id, inviteeId }),
});
if (res.status === 409) { zeigePopup('Bereits eingeladen', `${inviteeName} ist bereits eingeladen oder nimmt schon am Spiel teil.`); return; }
if (!res.ok) throw new Error();
const data = await res.json();
playerInvitations[id] = { einladungId: data.einladungId, status: 'PENDING', inviteeId, inviteeName };
if (inviteePicture) { playerProfilePics[id] = inviteePicture; updatePlayerHeader(id, inviteeName); }
renderPending(id); startPoll();
} catch (_) { zeigePopup('Fehler', 'Einladung konnte nicht gesendet werden. Bitte versuche es erneut.'); }
}
async function ensureDraftExists() {
if (!setupId) return;
await saveDraftToServer();
}
function buildSettingsFromDom() {
const spieldauerIdx = parseInt(document.getElementById('sldSpieldauer')?.value || '2');
const sd = SPIELDAUER[spieldauerIdx] || SPIELDAUER[2];
return { aufgabenProLevel: sd.aufgaben };
}
function buildSetupJsonFromDom() {
const settings = buildSettingsFromDom();
const mitspieler = playerIds.map(id => {
const inv = playerInvitations[id];
if (inv && (inv.status === 'ACCEPTED_OWN' || inv.status === 'ACCEPTED_HOST' || inv.status === 'PENDING')) {
return { name: inv.inviteeName || '', userId: inv.inviteeId,
eigenesGeraet: inv.status === 'ACCEPTED_OWN',
geschlecht: null, spieltMit: [], rollen: [], werkzeuge: [] };
}
const name = document.getElementById(`p${id}-name`)?.value?.trim() || '';
const werkzeuge = getChecked(`p${id}-werkzeuge`);
return { name, geschlecht: null, spieltMit: [], rollen: [], werkzeuge,
userId: id === selfPlayerId ? myUserId : null,
eigenesGeraet: false };
});
return JSON.stringify({ settings, mitspieler });
}
async function saveDraftToServer() {
if (!setupId) return;
const settingsJson = JSON.stringify(buildSettingsFromDom());
const setupJson = buildSetupJsonFromDom();
const selected = getSelectedGruppen();
const gruppenJson = selected.length ? JSON.stringify(selected) : null;
await fetch('/vanilla/setup', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setupId, settingsJson, setupJson, gruppenJson }),
}).catch(() => {});
}
function startPoll() { if (pollIntervalId) return; pollIntervalId = setInterval(pollEinladungen, 3000); }
function stopPoll() { if (pollIntervalId) { clearInterval(pollIntervalId); pollIntervalId = null; } }
async function pollEinladungen() {
const hasPending = Object.values(playerInvitations).some(inv => inv && inv.status === 'PENDING');
if (!hasPending) { stopPoll(); return; }
try {
const res = await fetch(`/vanilla/einladung?setupId=${setupId}`); if (!res.ok) return;
const liste = await res.json();
for (const e of liste) {
const id = playerIds.find(pid => playerInvitations[pid]?.einladungId === e.einladungId);
if (!id) continue;
const inv = playerInvitations[id]; if (!inv || inv.status === e.status) continue;
inv.status = e.status;
if (e.status === 'DECLINED' || e.status === 'CANCELLED') {
showMessage(`${inv.inviteeName} hat die Einladung abgelehnt oder abgebrochen.`, 'error');
} else if (e.status === 'ACCEPTED_HOST' && !inv.defaults) {
try {
const profileRes = await fetch(`/login/${e.inviteeId}`);
if (profileRes.ok) {
const profile = await profileRes.json();
const geschlecht = profile.geschlecht || null;
inv.defaults = { geschlecht, werkzeuge: geschlecht ? (WERKZEUGE_DEFAULTS[geschlecht] || []) : [] };
}
} catch (_) {}
}
renderPending(id);
}
} catch (_) {}
updateActionBtn();
}
function updateActionBtn() {
const hasPending = Object.values(playerInvitations).some(inv => inv && inv.status === 'PENDING');
document.getElementById('btnAction').disabled = hasPending;
}
function restorePlayer(id, data) {
const werkzeuge = (data.werkzeuge && data.werkzeuge.length > 0)
? data.werkzeuge
: (data.geschlecht ? (WERKZEUGE_DEFAULTS[data.geschlecht] || []) : []);
werkzeuge.forEach(val => { const cb = document.querySelector(`input[name="p${id}-werkzeuge"][value="${val}"]`); if (cb) { cb.checked = true; cb.closest('.check-item')?.classList.add('is-checked'); } });
}
async function ladeEinladungenAusDb(userIdToInfo) {
try {
const res = await fetch(`/vanilla/einladung?setupId=${setupId}`); if (!res.ok) return;
const einladungen = await res.json();
const aktive = einladungen.filter(e => ['PENDING','ACCEPTED_OWN','ACCEPTED_HOST'].includes(e.status));
for (const e of aktive) {
let playerId = userIdToInfo?.[e.inviteeId]?.playerId ?? playerIds.find(pid => pid === e.slotIndex);
if (!playerId) continue;
const inviteeName = userIdToInfo?.[e.inviteeId]?.name || e.inviteeName || '';
playerInvitations[playerId] = { einladungId: e.einladungId, status: e.status, inviteeId: e.inviteeId, inviteeName };
renderPending(playerId);
}
if (aktive.some(e => e.status === 'PENDING')) startPoll();
updateActionBtn();
} catch (_) {}
}
// ── Action handler ──
function handleAction() {
if (isGuestMode) bereitMachen(); else spielStarten();
}
// ── Validate players → returns mitspieler array or null ──
function validiereUndSammleMitspieler() {
let valid = true;
const mitspieler = playerIds.map(id => {
const inv = playerInvitations[id];
if (inv && inv.status === 'ACCEPTED_OWN') {
return { name: inv.inviteeName, geschlecht: null, spieltMit: [], rollen: [], werkzeuge: [],
userId: inv.inviteeId, eigenesGeraet: true, einladungId: inv.einladungId };
}
const name = document.getElementById(`p${id}-name`)?.value.trim() || '';
const werkzeuge = getChecked(`p${id}-werkzeuge`);
setFieldError(`p${id}-name-err`, !name);
setFieldError(`p${id}-werkzeuge-err`, werkzeuge.length === 0);
if (!name || !werkzeuge.length) valid = false;
return { name, geschlecht: null, spieltMit: [], rollen: [], werkzeuge,
userId: inv ? inv.inviteeId : (id === selfPlayerId ? myUserId : null),
eigenesGeraet: false };
});
if (!valid) { return null; }
return mitspieler;
}
// ── Spiel starten ──
async function spielStarten() {
hideMessage();
if (Object.values(playerInvitations).some(inv => inv && inv.status === 'PENDING')) {
showMessage('Bitte warte, bis alle Einladungen beantwortet wurden.', 'error'); return;
}
const mitspieler = validiereUndSammleMitspieler();
if (mitspieler === null) {
ACC_IDS.forEach(a => { document.getElementById(`acc-${a}-btn`)?.classList.remove('is-open'); document.getElementById(`acc-${a}-body`)?.classList.remove('is-open'); });
document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open');
document.getElementById('acc-grundeinstellungen-body').classList.add('is-open');
if (!document.getElementById('message').style.display || document.getElementById('message').style.display === 'none')
showMessage('Bitte alle Felder für jeden Spieler ausfüllen.', 'error');
return;
}
if (getSelectedGruppen().length === 0) {
ACC_IDS.forEach(a => { document.getElementById(`acc-${a}-btn`)?.classList.remove('is-open'); document.getElementById(`acc-${a}-body`)?.classList.remove('is-open'); });
document.getElementById('acc-aufgaben-btn').classList.add('is-open');
document.getElementById('acc-aufgaben-body').classList.add('is-open');
showMessage('Bitte mindestens eine Aufgaben-Gruppe auswählen.', 'error'); return;
}
const btn = document.getElementById('btnAction'); btn.disabled = true;
const content = await ladeGruppenContent();
// ACCEPTED_OWN: bereit prüfen und Daten laden
if (mitspieler.some(p => p.eigenesGeraet)) {
const einladungRes = await fetch(`/vanilla/einladung?setupId=${setupId}`);
if (einladungRes.ok) {
const einladungen = await einladungRes.json();
const nichtBereit = einladungen.filter(e => e.status === 'ACCEPTED_OWN' && !e.bereit);
if (nichtBereit.length > 0) {
showMessage('Noch nicht alle Mitspieler auf eigenem Gerät haben sich bereit erklärt.', 'error');
btn.disabled = false; return;
}
for (const p of mitspieler) {
if (!p.eigenesGeraet || !p.einladungId) continue;
const inv = einladungen.find(e => e.einladungId === p.einladungId);
if (inv && inv.spielerDatenJson) {
const daten = JSON.parse(inv.spielerDatenJson);
p.geschlecht = daten.geschlecht; p.spieltMit = daten.spieltMit || [];
p.rollen = daten.rollen || []; p.werkzeuge = daten.werkzeuge || [];
}
}
}
}
// Toy-Filter
const checkedToyIds = new Set([...document.querySelectorAll('#toyList input[type="checkbox"]:checked')].map(cb => cb.value));
const toyMap = new Map();
[...content.aufgaben, ...content.finisher].forEach(item =>
(item.benoetigteToys || []).forEach(t => toyMap.set(t.toyId, t)));
const checkedToys = [...checkedToyIds].map(id => toyMap.get(id)).filter(Boolean);
sessionStorage.setItem('vanilla-session-toys', JSON.stringify(checkedToys));
function toyOk(item) { const t = item.benoetigteToys||[]; return t.length === 0 || t.every(x => checkedToyIds.has(x.toyId)); }
const gameContent = {
aufgaben: content.aufgaben.filter(toyOk),
finisher: content.finisher.filter(toyOk),
};
const spieldauerIdx = parseInt(document.getElementById('sldSpieldauer').value);
const sd = SPIELDAUER[spieldauerIdx];
const settings = {
aufgabenProLevel: sd.aufgaben,
};
const { errors, warnings } = validateContent(gameContent, mitspieler);
if (errors.length > 0) { showValidation(errors, warnings, false); warnungsAkzeptiert = false; btn.disabled = false; return; }
if (warnings.length > 0 && !warnungsAkzeptiert) { showValidation([], warnings, true); warnungsAkzeptiert = true; btn.disabled = false; return; }
const selected = getSelectedGruppen();
sessionStorage.setItem('vanilla-session-gruppen', JSON.stringify(selected));
sessionStorage.setItem('vanilla-session-game', JSON.stringify(gameContent));
const sessionSetup = JSON.stringify({ settings, mitspieler });
sessionStorage.setItem('vanilla-session-settings', JSON.stringify(settings));
sessionStorage.setItem('vanilla-session-setup', sessionSetup);
try {
const sessionRes = await fetch('/vanilla', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
aufgabenProLevel: settings.aufgabenProLevel,
setupId: setupId,
}),
});
if (sessionRes.status === 409) throw new Error('Du hast bereits ein laufendes Vanilla-Spiel. Bitte beende es zuerst.');
if (!sessionRes.ok) throw new Error('Session konnte nicht angelegt werden.');
const location = sessionRes.headers.get('Location');
const sessionId = location.split('/').pop();
for (const p of mitspieler) {
const res = await fetch(`/vanilla/${sessionId}/mitspieler`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: p.name, geschlecht: p.geschlecht, spieltMit: p.spieltMit,
rollen: p.rollen, verfuegbareWerkzeuge: p.werkzeuge, userId: p.userId || null,
eigenesGeraet: p.eigenesGeraet || false }),
});
if (!res.ok) throw new Error(`Mitspieler "${p.name}" konnte nicht hinzugefügt werden.`);
}
const aufgabenRes = await fetch(`/vanilla/${sessionId}/aufgaben`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gameContent),
});
if (!aufgabenRes.ok) throw new Error('Aufgaben konnten nicht gespeichert werden.');
sessionStorage.setItem('vanilla-session-id', sessionId);
fetch('/vanilla/setup', { method: 'DELETE' }).catch(() => {});
window.location.href = '/games/vanilla/vanillaingame.html';
} catch (e) { showMessage(e.message, 'error'); btn.disabled = false; }
}
// ── Gast: Bereit ──
async function bereitMachen() {
const id = guestOwnPlayerId; if (!id) return;
const werkzeuge = getChecked(`p${id}-werkzeuge`);
setFieldError(`p${id}-werkzeuge-err`, werkzeuge.length === 0);
if (!werkzeuge.length) {
showMessage('Bitte mindestens ein Werkzeug auswählen.', 'error'); return;
}
try {
const res = await fetch(`/vanilla/einladung/${guestEinladungId}/spielerdaten`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spielerDatenJson: JSON.stringify({
geschlecht: null, spieltMit: [], rollen: [], werkzeuge,
})}),
});
if (!res.ok) throw new Error();
document.getElementById('setupView').style.display = 'none';
document.getElementById('waitView').style.display = 'block';
guestPollInterval = setInterval(pruefeSpielstart, 3000);
pruefeSpielstart();
} catch (_) { showMessage('Fehler beim Speichern. Bitte erneut versuchen.', 'error'); }
}
async function pruefeSpielstart() {
try {
const res = await fetch(`/vanilla/einladung/${guestEinladungId}`); if (!res.ok) return;
const data = await res.json();
if (data.status === 'CANCELLED') {
stopGuestPoll();
document.getElementById('setupView').style.display = 'block';
document.getElementById('waitView').style.display = 'none';
showMessage('Die Einladung wurde abgebrochen.', 'error'); return;
}
if (data.sessionId) {
try {
const mRes = await fetch(`/vanilla/${data.sessionId}/mitspieler/me`);
if (mRes.status === 200) {
const mData = await mRes.json();
sessionStorage.setItem('vanilla-guest-mitspieler-id', mData.mitspielerId);
sessionStorage.setItem('vanilla-guest-name', mData.name);
sessionStorage.setItem('vanilla-session-id', data.sessionId);
sessionStorage.setItem('vanilla-is-guest', 'true');
stopGuestPoll(); window.location.replace('/games/vanilla/vanillaingame.html');
}
} catch (_) {}
}
} catch (_) {}
}
function stopGuestPoll() { if (guestPollInterval) { clearInterval(guestPollInterval); guestPollInterval = null; } }
async function abbrechenGast() {
stopGuestPoll();
await fetch(`/vanilla/einladung/${guestEinladungId}`, { method: 'DELETE' }).catch(() => {});
window.location.href = '/userhome.html';
}
// ── Session-Modal ──
function zeigeSessionModal(title, text, actions) {
document.getElementById('sessionModalTitle').textContent = title;
const textEl = document.getElementById('sessionModalText'); textEl.textContent = text; textEl.style.display = text ? '' : 'none';
const actEl = document.getElementById('sessionModalActions'); actEl.innerHTML = '';
actions.forEach(a => { const btn = document.createElement('button'); btn.textContent = a.label; btn.className = a.primary ? 'full-width' : 'full-width secondary'; btn.onclick = () => a.onClick(); actEl.appendChild(btn); });
document.getElementById('sessionModal').style.display = 'flex';
}
function sessionFortfahren(sid) {
VANILLA_STORAGE_KEYS.forEach(k => sessionStorage.removeItem(k));
sessionStorage.setItem('vanilla-session-id', sid); window.location.href = '/games/vanilla/vanillaingame.html';
}
function sessionBeendenFragen(sid) {
zeigeSessionModal('Session wirklich beenden?', 'Die Session wird gelöscht.', [
{ label: 'Ja, beenden', primary: true, onClick: () => sessionLoeschen(sid) },
{ label: 'Nein, fortfahren', onClick: () => sessionFortfahren(sid) },
]);
}
async function sessionLoeschen(sid) {
document.getElementById('sessionModal').style.display = 'none';
try { await fetch('/vanilla', { method: 'DELETE', headers: {'Content-Type':'application/json'}, body: JSON.stringify({sessionId:sid}) }); } catch(_) {}
VANILLA_STORAGE_KEYS.forEach(k => sessionStorage.removeItem(k));
fetch('/vanilla/setup', { method: 'DELETE' }).catch(() => {});
}
async function pruefeChastityConstraint(playerId, userId) {
if (!userId) return;
try {
const res = await fetch(`/bdsm/chastity-constraint?userId=${userId}`); if (!res.ok) return;
const { lockedWerkzeug } = await res.json(); if (!lockedWerkzeug) return;
const locked = lockedWerkzeug === 'BOTH' ? ['VAGINA','PENIS'] : [lockedWerkzeug];
locked.forEach(w => sperrWerkzeugCheckbox(playerId, w));
const hintEl = document.getElementById(`p${playerId}-chastity-hint`);
if (hintEl) hintEl.dataset.hintText = '🔒 Das System weiß aus halbwegs verlässlicher Quelle, dass diese Person dieses Körperteil gerade nicht einsetzen kann.';
} catch (_) {}
}
function sperrWerkzeugCheckbox(playerId, werkzeug) {
const cb = document.querySelector(`input[name="p${playerId}-werkzeuge"][value="${werkzeug}"]`); if (!cb) return;
cb.checked = false; cb.disabled = false;
const lbl = cb.closest('.check-item');
if (lbl) { lbl.classList.remove('is-checked','is-disabled'); lbl.dataset.chastitylocked = '1'; }
}
function flashChastityHint(playerId) {
const hintEl = document.getElementById(`p${playerId}-chastity-hint`); const text = hintEl?.dataset.hintText; if (!text) return;
document.getElementById('errorModalTitle').textContent = 'Nicht verfügbar';
document.getElementById('errorModalText').textContent = text;
document.getElementById('errorModal').style.display = 'flex';
}
function zeigePopup(title, text) { document.getElementById('errorModalTitle').textContent = title; document.getElementById('errorModalText').textContent = text; document.getElementById('errorModal').style.display = 'flex'; }
function showMessage(text, type) { const el = document.getElementById('message'); el.textContent = text; el.className = `message ${type}`; el.style.display = 'block'; }
function hideMessage() { document.getElementById('message').style.display = 'none'; }
// ── Init ──
async function init() {
try {
const meRes = await fetch('/login/me');
if (!meRes.ok) { window.location.replace('/login.html'); return; }
const user = await meRes.json();
myUserId = user?.userId || null;
const sessionRes = await fetch(`/vanilla?userId=${user.userId}`);
if (sessionRes.status === 200) {
const session = await sessionRes.json();
await initHostSetup(user);
zeigeSessionModal('Aktive Session vorhanden', 'Du hast noch eine laufende Session. Möchtest du fortfahren?', [
{ label: 'Ja, fortfahren', primary: true, onClick: () => sessionFortfahren(session.sessionId) },
{ label: 'Nein', onClick: () => sessionBeendenFragen(session.sessionId) },
]);
return;
}
const einladungRes = await fetch('/vanilla/einladung/meine-aktive');
if (einladungRes.status === 200) {
const einladung = await einladungRes.json();
if (einladung.sessionId) {
try { const mRes = await fetch(`/vanilla/${einladung.sessionId}/mitspieler/me`); if (mRes.ok) { const mData = await mRes.json(); sessionStorage.setItem('vanilla-guest-mitspieler-id', mData.mitspielerId); sessionStorage.setItem('vanilla-guest-name', mData.name); } } catch (_) {}
sessionStorage.setItem('vanilla-session-id', einladung.sessionId);
sessionStorage.setItem('vanilla-is-guest', 'true');
window.location.replace('/games/vanilla/vanillaingame.html'); return;
}
await initGuestMode(einladung, user); return;
}
await initHostSetup(user);
} catch (_) { document.getElementById('setupView').style.display = 'block'; }
}
async function ladeGruppenListen() {
try {
const [own, abo, system] = await Promise.all([
fetch('/vanilla/gruppe/list/user?page=0&size=500').then(r => r.ok ? r.json() : { content: [] }).catch(() => ({ content: [] })),
fetch('/vanilla/abo/list?page=0&size=500').then(r => r.ok ? r.json() : { content: [] }).catch(() => ({ content: [] })),
fetch('/vanilla/gruppe/list/system?page=0&size=500').then(r => r.ok ? r.json() : { content: [] }).catch(() => ({ content: [] })),
]);
renderGruppeList('listOwn', own.content || []);
renderGruppeList('listSubscribed', abo.content || []);
renderGruppeList('listSystem', system.content || []);
} catch (_) {
renderGruppeList('listOwn', []);
renderGruppeList('listSubscribed', []);
renderGruppeList('listSystem', []);
}
}
async function initHostSetup(user) {
if (!sessionStorage.getItem('vanilla-setup-id')) sessionStorage.setItem('vanilla-setup-id', crypto.randomUUID());
setupId = sessionStorage.getItem('vanilla-setup-id');
document.getElementById('setupView').style.display = 'block';
let restoredFromSetup = false;
const savedSetup = sessionStorage.getItem('vanilla-session-setup');
if (savedSetup) {
try {
const { settings, mitspieler } = JSON.parse(savedSetup);
if (mitspieler && mitspieler.length > 0) {
if (settings) applySettings(settings);
const userIdToInfo = {};
mitspieler.forEach((p, i) => {
if (playerIds.length >= MAX_PLAYERS) return;
const id = addPlayer(p.name, i === 0, i === 0, false, false);
if (id) { restorePlayer(id, p); if (p.userId) userIdToInfo[p.userId] = { playerId: id, name: p.name }; }
});
Object.entries(userIdToInfo).forEach(([userId, info]) => { pruefeChastityConstraint(info.playerId, userId); });
await ladeEinladungenAusDb(userIdToInfo);
restoredFromSetup = true;
} else {
sessionStorage.removeItem('vanilla-session-setup');
}
} catch (_) {}
}
if (!restoredFromSetup) {
const savedSettings = sessionStorage.getItem('vanilla-session-settings');
if (savedSettings) {
applySettings(JSON.parse(savedSettings));
} else {
let draft = null;
try {
const res = await fetch('/vanilla/setup');
if (res.status === 200) draft = await res.json();
} catch (_) {}
if (draft && (draft.settingsJson || draft.setupJson || draft.gruppenJson)) {
const loadOld = await new Promise(resolve => {
zeigeSessionModal(
'Konfiguration gefunden',
'Es gibt noch eine unvollständige Einrichtung vom letzten Mal. Möchtest du fortfahren oder neu beginnen?',
[
{ label: 'Fortfahren', primary: true, onClick: () => { document.getElementById('sessionModal').style.display = 'none'; resolve(true); } },
{ label: 'Neu beginnen', onClick: () => { document.getElementById('sessionModal').style.display = 'none'; resolve(false); } },
]
);
});
if (loadOld) {
if (draft.setupId) { sessionStorage.setItem('vanilla-setup-id', draft.setupId); setupId = draft.setupId; }
if (draft.settingsJson) { sessionStorage.setItem('vanilla-session-settings', draft.settingsJson); applySettings(JSON.parse(draft.settingsJson)); }
if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); }
} else {
if (draft.setupId) {
try {
const invRes = await fetch(`/vanilla/einladung?setupId=${draft.setupId}`);
if (invRes.status === 200) {
const liste = await invRes.json();
await Promise.all(
liste
.filter(e => ['PENDING','ACCEPTED_OWN','ACCEPTED_HOST'].includes(e.status))
.map(e => fetch(`/vanilla/einladung/${e.einladungId}`, { method: 'DELETE' }).catch(() => {}))
);
}
} catch (_) {}
await fetch('/vanilla/setup', { method: 'DELETE' }).catch(() => {});
}
const newId = crypto.randomUUID();
sessionStorage.setItem('vanilla-setup-id', newId); setupId = newId;
sessionStorage.removeItem('vanilla-session-settings');
sessionStorage.removeItem('vanilla-session-gruppen');
}
} else if (draft) {
if (draft.setupId) { sessionStorage.setItem('vanilla-setup-id', draft.setupId); setupId = draft.setupId; }
if (draft.settingsJson) { sessionStorage.setItem('vanilla-session-settings', draft.settingsJson); applySettings(JSON.parse(draft.settingsJson)); }
if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); }
}
}
const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
const selfGeschlecht = defaults.geschlecht || user?.geschlecht || null;
const selfWerkzeuge = defaults.werkzeuge?.length ? defaults.werkzeuge : (selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : []);
const selfId = addPlayer(user ? user.name : '', true, true, !!selfGeschlecht, false);
if (selfId) {
if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); }
if (playerIds.length < MAX_PLAYERS) addPlayer();
restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: selfWerkzeuge });
if (myUserId) pruefeChastityConstraint(selfId, myUserId);
}
await ladeEinladungenAusDb(null);
}
try {
savedGruppen = new Set(JSON.parse(sessionStorage.getItem('vanilla-session-gruppen') || '[]'));
} catch (_) { savedGruppen = new Set(); }
await ladeGruppenListen();
openPlayerAcc(playerIds[0]);
}
async function initGuestMode(einladung, user) {
isGuestMode = true;
guestEinladungId = einladung.einladungId;
document.getElementById('pageSubtitle').textContent = 'Konfiguriere deine Präferenzen';
document.getElementById('btnAction').textContent = 'Bereit';
document.getElementById('btnAddPlayer').style.display = 'none';
disableSettingsForGuest();
if (einladung.setupId) {
try {
const draft = await fetch(`/vanilla/setup?setupId=${einladung.setupId}`).then(r => r.ok ? r.json() : null).catch(() => null);
if (draft?.settingsJson) applySettings(JSON.parse(draft.settingsJson));
if (draft?.setupJson) {
const { mitspieler } = JSON.parse(draft.setupJson);
mitspieler.forEach((p, i) => {
if (playerIds.length >= MAX_PLAYERS) return;
const isOwnSlot = einladung.slotIndex != null ? (i + 1 === einladung.slotIndex) : (p.eigenesGeraet && p.userId === myUserId);
const id = addPlayer(isOwnSlot ? (user?.name || p.name || '') : (p.name || ''), isOwnSlot, !isOwnSlot, !isOwnSlot, !isOwnSlot);
if (id) { if (isOwnSlot) { guestOwnPlayerId = id; selfPlayerId = id; } else restorePlayer(id, p); }
});
}
if (draft?.gruppenJson) { savedGruppen = new Set(JSON.parse(draft.gruppenJson)); await ladeGruppenListen(); }
} catch (_) {}
}
if (!guestOwnPlayerId) {
const id = addPlayer(user?.name || '', true, true, false, false);
guestOwnPlayerId = id; selfPlayerId = id;
}
if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); }
const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
restorePlayer(guestOwnPlayerId, { geschlecht: defaults.geschlecht || user?.geschlecht || null, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: defaults.werkzeuge || [] });
if (myUserId) pruefeChastityConstraint(guestOwnPlayerId, myUserId);
document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open');
document.getElementById('acc-grundeinstellungen-body').classList.add('is-open');
openPlayerAcc(guestOwnPlayerId);
document.getElementById('setupView').style.display = 'block';
}
init();
</script>
</body>
</html>