feat: der letzte Auftrag - Vollständiges Deployment

- Frontend: HTML/CSS/JS Spielapp (1642 Zeilen)
- Backend: Node.js/Express + SQLite server
- REST API: game, spieler, notes
- AI_INSTRUCTIONS.md documentation
This commit is contained in:
Clawdia
2026-05-08 10:47:49 +02:00
parent 2cd6181492
commit df49ba3f63
2 changed files with 397 additions and 262 deletions
+209 -262
View File
@@ -1256,165 +1256,147 @@
</div><!-- /app --> </div><!-- /app -->
<script> <script>
// ══════════════════════════════════════════ // ══════════════════════════════════════════
// DATA // PART 1: API-HELPERS & STATE-SYNC
// ══════════════════════════════════════════ // ══════════════════════════════════════════
const quests = [ const API = '/api';
{
id: 0,
badge: 'easy',
badgeLabel: 'EINSTEIGER',
name: 'Das Lied der Liebe',
holder: 'Hüter des <strong>Briefes</strong>',
item: '💌 Brief des ersten Dates',
desc: 'Der Held muss der Runde einen Liebesfilm-Titel nennen und ihn pantomimisch darstellen die anderen raten.',
success: '✓ Brief bekommt er',
fail: '✗ Ein Schluck',
details: `<strong>Ablauf:</strong> Der Hüter flüstert dem Held einen Filmtitel ins Ohr (z.B. Titanic, Die Schöne und das Biest, Notting Hill). Der Held darf nicht sprechen und muss den Titel pantomimisch darstellen. Zeitlimit: 60 Sekunden.`,
},
{
id: 1,
badge: 'easy',
badgeLabel: 'EINSTEIGER',
name: 'Der Wissenstest',
holder: 'Hüter des <strong>Rings</strong>',
item: '💍 Ring der Verbindung',
desc: 'Der Hüter stellt 5 Fragen über die Braut. Der Held muss mindestens 3 richtig beantworten.',
success: '✓ Ring bekommt er',
fail: '✗ Pro Falschantwort ein Schluck',
details: `<strong>Fragen vorbereiten (Hüter):</strong> Wähle 5 aus diesen Kategorien:<br>
• Lieblingsessen? • Lieblingsfilm? • Wo wollte sie immer hinreisen?<br>
• Was ist ihr größter Tick? • Wie heißt ihre beste Freundin?<br>
• Was war ihr erstes Haustier? • Was mag sie am Bräutigam am meisten?<br><br>
<strong>Hinweis:</strong> Fragen vorab mit der Braut abstimmen.`,
},
{
id: 2,
badge: 'easy',
badgeLabel: 'EINSTEIGER',
name: 'Das Duell der Münzen',
holder: 'Hüter des <strong>Schlüssels</strong>',
item: '🔑 Schlüssel zur Zukunft',
desc: 'Münze oder Kronkorken werfen: Wer 3 von 5 Würfen in einen Becher trifft, gewinnt.',
success: '✓ Schlüssel bekommt er',
fail: '✗ Ein ordentlicher Schluck',
details: `<strong>Setup:</strong> Einen Becher auf den Tisch stellen, ca. 12 Meter Abstand. Jeder wirft abwechselnd (Held vs. Hüter). Bester aus 5 Versuchen gewinnt.<br><br>
<strong>Regelung bei Gleichstand:</strong> Plötzlicher Tod wer als nächstes trifft, gewinnt.`,
},
{
id: 3,
badge: 'easy',
badgeLabel: 'EINSTEIGER',
name: 'Der Schwur der tausend Worte',
holder: 'Hüter der <strong>Blume</strong>',
item: '🌹 Blume der Ewigkeit',
desc: 'Der Held muss in 90 Sekunden einen Heiratsantrag improvisieren vor allen Gästen.',
success: '✓ Blume bekommt er',
fail: '✗ Kein Applaus = trinken',
details: `<strong>Ablauf:</strong> Der Held hat 90 Sekunden Zeit für einen improvisierten Heiratsantrag an die (nicht anwesende) Braut. Die Gäste klatschen am Ende.<br><br>
<strong>Bewertung:</strong> Lautester Applaus = Erfolg. Stille oder Widerspruch = Misserfolg. Der Hüter entscheidet im Zweifelsfall.`,
},
{
id: 4,
badge: 'medium',
badgeLabel: 'MITTEL',
name: 'Die Chronik der Scham',
holder: 'Hüter des <strong>Fotos</strong>',
item: '📸 Foto der gemeinsamen Zeit',
desc: 'Der Hüter erzählt eine peinliche Geschichte des Helden. Wer davon die meisten Details wusste, gewinnt.',
success: '✓ Foto und eine Geschichte über die Braut zurück',
fail: '✗ Der Held trinkt und erzählt selbst die Geschichte',
details: `<strong>Ablauf:</strong> Der Hüter hat vorab eine peinliche Geschichte über den Helden vorbereitet. Er liest sie vor und stellt danach 3 Detailfragen. Kann der Held 2 von 3 beantworten? → Erfolg.<br><br>
<strong>Empfehlung:</strong> Eine Person, die den Helden gut kennt, übernimmt diese Rolle.`,
},
{
id: 5,
badge: 'medium',
badgeLabel: 'MITTEL',
name: 'Das Blindverkostung',
holder: 'Hüter des <strong>Schuhs</strong>',
item: '👠 Schuh der Flucht',
desc: '5 Getränke blind verkosten und benennen. Mindestens 3 richtig.',
success: '✓ Schuh bekommt er',
fail: '✗ Alle falsch erkannten Getränke austrinken',
details: `<strong>Vorbereitung:</strong> 5 Getränke in nummerierten Bechern vorbereiten (Mischung aus Alkohol und Softdrinks). Augenbinde aufsetzen.<br><br>
<strong>Regeln:</strong> Riechen ist erlaubt. Geraten werden darf nur auf Basis der Verkostung.`,
},
{
id: 6,
badge: 'hard',
badgeLabel: 'HERAUSFORDERUNG',
name: 'Der Zweikampf der Weisheit',
holder: 'Hüter des <strong>Maskottchens</strong>',
item: '🧸 Maskottchen des Heils',
desc: 'Ein Trivia-Duell: Pop-Kultur, Warhammer & DnD-Fragen. Wer 3 von 5 beantwortet, gewinnt.',
success: '✓ Maskottchen und ein Ehrentitel',
fail: '✗ Zwei große Schlucke',
details: `<strong>Beispielfragen:</strong><br>
• RPG: „Wie viele Seiten hat ein W20?" (20)<br>
• Warhammer: „Welche Fraktion trägt Schädelornamente als Zeichen der Loyalität?" (Space Marines / diverse)<br>
• DnD: „Was ist ein nat. 20?" (Kritischer Erfolg)<br>
• Allgemein: „Wer schrieb 'Der Herr der Ringe'?" (Tolkien)<br>
• Persönlich: Eine Frage über das Paar.<br><br>
<strong>Hinweis:</strong> Schwierigkeitsgrad der Fragen an die Gruppe anpassen.`,
},
{
id: 7,
badge: 'hard',
badgeLabel: 'HERAUSFORDERUNG',
name: 'Die letzte Prüfung',
holder: 'Hüter der <strong>Urkunde</strong>',
item: '🏆 Urkunde der Heldentaten',
desc: 'Der Held muss einen Abend-Rückblick halten: Die 5 peinlichsten Momente des Abends, dramatisch vorgetragen.',
success: '✓ Urkunde + Applaus der Legende',
fail: '✗ Nochmal von vorne lauter!',
details: `<strong>Ablauf:</strong> Diese Quest steht am Ende des Abends. Der Held fasst in einer Rede (23 Min.) die Ereignisse des Abends zusammen pointiert und feierlich vorgetragen.<br><br>
<strong>Bewertung:</strong> Stehende Ovationen = Erfolg. Unzureichende Darbietung = Neuversuch.<br><br>
<strong>Abschluss:</strong> Nach bestandener Quest wird die Urkunde feierlich überreicht.`,
},
];
async function apiGet(url) {
try {
const r = await fetch(API + url);
return r.ok ? r.json() : null;
} catch(e) { console.error('GET failed:', e); return null; }
}
async function apiPost(url, body) {
try {
const r = await fetch(API + url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body || {})
});
return r.ok ? r.json() : null;
} catch(e) { console.error('POST failed:', e); return null; }
}
async function apiPut(url, body) {
try {
const r = await fetch(API + url, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body || {})
});
return r.ok ? r.json() : null;
} catch(e) { console.error('PUT failed:', e); return null; }
}
async function apiDelete(url) {
try {
const r = await fetch(API + url, {method: 'DELETE'});
return r.ok ? r.json() : null;
} catch(e) { console.error('DELETE failed:', e); return null; }
}
// Lokale State-Variablen
const itemCollected = new Array(8).fill(false); const itemCollected = new Array(8).fill(false);
let drinks = 0; let drinks = 0;
const questDone = new Array(quests.length).fill(false); const questDone = new Array(8).fill(false);
// ══════════════════════════════════════════ // State vom Server laden
// STARS async function syncState() {
// ══════════════════════════════════════════ const data = await apiGet('/game');
if (!data) return;
const s = data.state;
if (s.itemCollected) s.itemCollected.forEach((v, i) => { itemCollected[i] = v; });
if (s.drinks !== undefined) drinks = s.drinks;
if (s.questDone) s.questDone.forEach((v, i) => { questDone[i] = v; });
document.getElementById('drinkCount').textContent = drinks;
}
// State zum Server speichern
async function saveStateToServer(state) {
await apiPost('/game/save-state', { ...state, timestamp: Date.now() });
}
// Lokale UI aktualisieren
function refreshUI() {
for (let i = 0; i < 8; i++) {
const slot = document.getElementById('inv-' + i);
if (slot) slot.classList.toggle('collected', itemCollected[i]);
}
const dc = document.getElementById('drinkCount');
if (dc) dc.textContent = drinks;
updateQuestStatus();
renderSpieler();
}
// Sync + Refresh
async function updateAndSync(state) {
await saveStateToServer(state);
refreshUI();
}
// ══════════════════════════════════════════════════════
// PART 2: QUEST-DATEN
// ═══════════════════════════════════════════
const quests = [
{ id:0, badge:'easy', badgeLabel:'EINSTEIGER', name:'Das Lied der Liebe',
holder:'Hüter des <strong>Briefes</strong>', item:'💌 Brief des ersten Dates',
desc:'Der Held muss der Runde einen Liebesfilm-Titel nennen und ihn pantomimisch darstellen.',
success:'✓ Brief bekommt er', fail:'✗ Ein Schluck',
details:'Der Hüter flüstert dem Held einen Filmtitel ins Ohr. Der Held stellt ihn pantomimisch dar (60s).' },
{ id:1, badge:'easy', badgeLabel:'EINSTEIGER', name:'Der Wissenstest',
holder:'Hüter des <strong>Rings</strong>', item:'💍 Ring der Verbindung',
desc:'Der Hüter stellt 5 Fragen über die Braut. Mindestens 3 richtig = Gewinn.',
success:'✓ Ring bekommt er', fail:'✗ Pro Falschantwort ein Schluck',
details:'Fragen: Lieblingsessen, Lieblingsfilm, Reisetraum, größter Tick, beste Freundin.' },
{ id:2, badge:'easy', badgeLabel:'EINSTEIGER', name:'Das Duell der Münzen',
holder:'Hüter des <strong>Schlüssels</strong>', item:'🔑 Schlüssel zur Zukunft',
desc:'Münze oder Kronkorken werfen: Wer 3 von 5 in einen Becher trifft, gewinnt.',
success:'✓ Schlüssel bekommt er', fail:'✗ Ein ordentlicher Schluck',
details:'Becher 1-2m entfernt. Bester aus 5 Versuchen. Bei Gleichstand: plötzlicher Tod.' },
{ id:3, badge:'easy', badgeLabel:'EINSTEIGER', name:'Der Schwur der tausend Worte',
holder:'Hüter der <strong>Blume</strong>', item:'🌹 Blume der Ewigkeit',
desc:'Der Held muss in 90 Sekunden einen Heiratsantrag improvisieren vor allen Gästen.',
success:'✓ Blume bekommt er', fail:'✗ Kein Applaus = trinken',
details:'90s improvisierter Heiratsantrag. Lautester Applaus = Erfolg.' },
{ id:4, badge:'medium', badgeLabel:'MITTEL', name:'Die Chronik der Scham',
holder:'Hüter des <strong>Fotos</strong>', item:'📸 Foto der gemeinsamen Zeit',
desc:'Der Hüter erzählt eine peinliche Geschichte. Wer die meisten Details wusste, gewinnt.',
success:'✓ Foto und Geschichte über die Braut', fail:'✗ Held trinkt & erzählt selbst',
details:'Hüter liest peinliche Geschichte. 3 Detailfragen. 2/3 richtig = Erfolg.' },
{ id:5, badge:'medium', badgeLabel:'MITTEL', name:'Das Blindverkostung',
holder:'Hüter des <strong>Schuhs</strong>', item:'👠 Schuh der Flucht',
desc:'5 Getränke blind verkosten und benennen. Mindestens 3 richtig.',
success:'✓ Schuh bekommt er', fail:'✗ Alle falsch erkannten austrinken',
details:'5 nummerierte Becher (Alkohol+Softdrinks). Augenbinde auf. Riechen erlaubt.' },
{ id:6, badge:'hard', badgeLabel:'HERAUSFORDERUNG', name:'Der Zweikampf der Weisheit',
holder:'Hüter des <strong>Maskottchens</strong>', item:'🧸 Maskottchen des Heils',
desc:'Trivia-Duell: Pop-Kultur, Warhammer & DnD. Wer 3 von 5 beantwortet, gewinnt.',
success:'✓ Maskottchen und Ehrentitel', fail:'✗ Zwei große Schlucke',
details:'RPG/Warhammer/DnD/Filmfragen. Schwierigkeitsgrad anpassen.' },
{ id:7, badge:'hard', badgeLabel:'HERAUSFORDERUNG', name:'Die letzte Prüfung',
holder:'Hüter der <strong>Urkunde</strong>', item:'🏆 Urkunde der Heldentaten',
desc:'Abend-Rückblick: Die 5 peinlichsten Momente, dramatisch vorgetragen.',
success:'✓ Urkunde + Applaus der Legende', fail:'✗ Nochmal von vorne lauter!',
details:'2-3 Min Rede. Stehende Ovationen = Erfolg.' },
];
// ═══════════════════════════════════════════
// PART 3: STARS, TABS & START
// ═══════════════════════════════════════════
(function createStars() { (function createStars() {
const c = document.getElementById('stars'); const c = document.getElementById('stars');
if (!c) return;
for (let i = 0; i < 120; i++) { for (let i = 0; i < 120; i++) {
const s = document.createElement('div'); const s = document.createElement('div');
s.className = 'star'; s.className = 'star';
s.style.cssText = ` s.style.cssText = `left:${Math.random()*100}%;top:${Math.random()*100}%;
left:${Math.random()*100}%; --d:${2+Math.random()*4}s;--delay:${Math.random()*5}s;
top:${Math.random()*100}%; --bright:${0.4+Math.random()*0.6};width:${Math.random()<0.3?3:2}px;height:${Math.random()<0.3?3:2}px;`;
--d:${2+Math.random()*4}s;
--delay:${Math.random()*5}s;
--bright:${0.4+Math.random()*0.6};
width:${Math.random()<0.3?3:2}px;
height:${Math.random()<0.3?3:2}px;
`;
c.appendChild(s); c.appendChild(s);
} }
})(); })();
// ══════════════════════════════════════════
// START
// ══════════════════════════════════════════
function startApp() {
document.getElementById('cover').style.display = 'none';
document.getElementById('app').style.display = 'block';
buildQuests();
buildDice();
loadState();
loadSpieler();
updateQuestStatus();
}
// ══════════════════════════════════════════
// TABS
// ══════════════════════════════════════════
function showTab(id) { function showTab(id) {
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
@@ -1422,11 +1404,22 @@ function showTab(id) {
event.target.classList.add('active'); event.target.classList.add('active');
} }
// ══════════════════════════════════════════ function startApp() {
// QUESTS document.getElementById('cover').style.display = 'none';
// ══════════════════════════════════════════ document.getElementById('app').style.display = 'block';
buildQuests();
buildDice();
syncState();
syncSpieler();
updateQuestStatus();
}
// ═══════════════════════════════════════════
// PART 4: QUEST-FUNKTIONEN
// ═══════════════════════════════════════════
function buildQuests() { function buildQuests() {
const grid = document.getElementById('questGrid'); const grid = document.getElementById('questGrid');
if (!grid) return;
grid.innerHTML = ''; grid.innerHTML = '';
quests.forEach((q, i) => { quests.forEach((q, i) => {
const card = document.createElement('div'); const card = document.createElement('div');
@@ -1442,13 +1435,13 @@ function buildQuests() {
<span class="reward-fail">${q.fail}</span> <span class="reward-fail">${q.fail}</span>
</div> </div>
<div class="quest-expand">${q.details}<br><br> <div class="quest-expand">${q.details}<br><br>
<button onclick="event.stopPropagation(); toggleDone(${i})" <button onclick="event.stopPropagation(); toggleDone(${i})"
style="font-family:'Cinzel',serif; font-size:0.75rem; background:transparent; border:1px solid #8a6520; padding:0.3rem 0.8rem; cursor:pointer; color:#8a6520; border-radius:3px;"> style="font-family:'Cinzel',serif;font-size:0.75rem;background:transparent;border:1px solid #8a6520;
padding:0.3rem 0.8rem;cursor:pointer;color:#8a6520;border-radius:3px;">
${questDone[i] ? '↩ Als offen markieren' : '✓ Als erledigt markieren'} ${questDone[i] ? '↩ Als offen markieren' : '✓ Als erledigt markieren'}
</button> </button>
</div> </div>
<div class="expand-hint">▼ Aufklappen für Details</div> <div class="expand-hint">▼ Aufklappen für Details</div>`;
`;
card.addEventListener('click', () => toggleExpand(i)); card.addEventListener('click', () => toggleExpand(i));
grid.appendChild(card); grid.appendChild(card);
}); });
@@ -1456,57 +1449,61 @@ function buildQuests() {
function toggleExpand(i) { function toggleExpand(i) {
const card = document.getElementById('qcard-' + i); const card = document.getElementById('qcard-' + i);
card.classList.toggle('expanded'); if (card) card.classList.toggle('expanded');
} }
function toggleDone(i) { async function toggleDone(i) {
questDone[i] = !questDone[i]; questDone[i] = !questDone[i];
saveState(); const sv = await apiGet('/game');
if (!sv) return;
const s = {...sv.state, questDone: [...questDone]};
await updateAndSync(s);
buildQuests(); buildQuests();
updateQuestStatus(); updateQuestStatus();
} }
// ══════════════════════════════════════════ // ══════════════════════════════════════════
// INVENTORY // PART 5: INVENTORY & DRINKS
// ══════════════════════════════════════════ // ══════════════════════════════════════════
function toggleItem(i) { async function toggleItem(i) {
itemCollected[i] = !itemCollected[i]; itemCollected[i] = !itemCollected[i];
const slot = document.getElementById('inv-' + i); const slot = document.getElementById('inv-' + i);
slot.classList.toggle('collected', itemCollected[i]); if (slot) slot.classList.toggle('collected', itemCollected[i]);
saveState(); const sv = await apiGet('/game');
if (!sv) return;
const s = {...sv.state, itemCollected: [...itemCollected]};
await updateAndSync(s);
updateQuestStatus(); updateQuestStatus();
} }
// ══════════════════════════════════════════ async function changeDrinks(n) {
// DRINKS const res = await apiPost('/game/drink', {amount: n});
// ══════════════════════════════════════════ if (res && res.drinks !== undefined) {
function changeDrinks(n) { drinks = res.drinks;
drinks = Math.max(0, drinks + n); document.getElementById('drinkCount').textContent = drinks;
document.getElementById('drinkCount').textContent = drinks; }
saveState();
} }
// ══════════════════════════════════════════ // ══════════════════════════════════════════
// STATUS // PART 6: STATUS
// ══════════════════════════════════════════ // ══════════════════════════════════════════
function updateQuestStatus() { function updateQuestStatus() {
const count = itemCollected.filter(Boolean).length; const count = itemCollected.filter(Boolean).length;
document.getElementById('questCount').textContent = count + '/8'; const qc = document.getElementById('questCount');
if (qc) qc.textContent = count + '/8';
const list = document.getElementById('questStatusList'); const list = document.getElementById('questStatusList');
if (!list) return; if (!list) return;
list.innerHTML = quests.map((q, i) => ` list.innerHTML = quests.map((q, i) => `
<div style="display:flex; align-items:center; gap:0.5rem; font-size:0.9rem; color:${questDone[i]?'#2a5a1a':'#5a3a10'};"> <div style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;
color:${questDone[i]?'#2a5a1a':'#5a3a10'};">
<span>${questDone[i] ? '✓' : '○'}</span> <span>${questDone[i] ? '✓' : '○'}</span>
<span>${q.name}</span> <span>${q.name}</span>
<span style="margin-left:auto; font-size:0.8rem; opacity:0.7">${q.item}</span> <span style="margin-left:auto;font-size:0.8rem;opacity:0.7">${q.item}</span>
</div> </div>`).join('');
`).join('');
} }
// ═══════════════════════════════════════════
// ══════════════════════════════════════════ // PART 7: WÜRFEL
// DICE // ═══════════════════════════════════════════
// ══════════════════════════════════════════
function buildDice() { function buildDice() {
const btn = document.getElementById('diceBtns'); const btn = document.getElementById('diceBtns');
if (!btn) return; if (!btn) return;
@@ -1523,6 +1520,7 @@ function rollDice(sides) {
const result = Math.floor(Math.random() * sides) + 1; const result = Math.floor(Math.random() * sides) + 1;
const el = document.getElementById('diceResult'); const el = document.getElementById('diceResult');
const log = document.getElementById('diceLog'); const log = document.getElementById('diceLog');
if (!el || !log) return;
el.classList.remove('rolling'); el.classList.remove('rolling');
void el.offsetWidth; void el.offsetWidth;
el.classList.add('rolling'); el.classList.add('rolling');
@@ -1533,10 +1531,9 @@ function rollDice(sides) {
result >= Math.ceil(sides * 0.4) ? '~ Mittelmaß...' : '✗ Schwach. Trinken?'; result >= Math.ceil(sides * 0.4) ? '~ Mittelmaß...' : '✗ Schwach. Trinken?';
log.textContent = `W${sides}${result} · ${reactions}`; log.textContent = `W${sides}${result} · ${reactions}`;
} }
// ═══════════════════════════════════════════
// ══════════════════════════════════════════ // PART 8: SPIELER (API-Version)
// SPIELER // ═══════════════════════════════════════════
// ══════════════════════════════════════════
let spieler = []; let spieler = [];
const rolleLabels = { const rolleLabels = {
@@ -1548,6 +1545,13 @@ const rolleLabels = {
gast: '🍺 Gast', gast: '🍺 Gast',
}; };
async function syncSpieler() {
const data = await apiGet('/spieler');
if (data) spieler = data;
populateQuestSelect();
renderSpieler();
}
function populateQuestSelect() { function populateQuestSelect() {
const sel = document.getElementById('sQuest'); const sel = document.getElementById('sQuest');
if (!sel) return; if (!sel) return;
@@ -1562,7 +1566,7 @@ function populateQuestSelect() {
sel.value = current; sel.value = current;
} }
function addSpieler() { async function addSpieler() {
const nameEl = document.getElementById('sName'); const nameEl = document.getElementById('sName');
const rolleEl = document.getElementById('sRolle'); const rolleEl = document.getElementById('sRolle');
const questEl = document.getElementById('sQuest'); const questEl = document.getElementById('sQuest');
@@ -1570,32 +1574,32 @@ function addSpieler() {
if (!name) { nameEl.focus(); return; } if (!name) { nameEl.focus(); return; }
const rolle = rolleEl.value; const rolle = rolleEl.value;
const questIdx = questEl.value !== '' ? parseInt(questEl.value) : null; const questIdx = questEl.value !== '' ? parseInt(questEl.value) : null;
spieler.push({ id: Date.now(), name, rolle, questIdx }); const res = await apiPost('/spieler', {name, rolle, quest_id: questIdx});
nameEl.value = ''; if (res && res.spieler) {
questEl.value = ''; spieler.push(res.spieler);
saveSpieler(); nameEl.value = '';
renderSpieler(); questEl.value = '';
renderSpieler();
}
} }
function deleteSpieler(id) { async function deleteSpieler(id) {
await apiDelete('/spieler/' + id);
spieler = spieler.filter(s => s.id !== id); spieler = spieler.filter(s => s.id !== id);
saveSpieler();
renderSpieler(); renderSpieler();
} }
function changeRolle(id, rolle) { async function changeRolle(id, rolle) {
const s = spieler.find(s => s.id === id); const s = spieler.find(s => s.id === id);
if (s) { s.rolle = rolle; saveSpieler(); renderSpieler(); } if (s) { s.rolle = rolle; await apiPut('/spieler/' + id, {rolle}); renderSpieler(); }
} }
function changeQuest(id, val) { async function changeQuest(id, val) {
const s = spieler.find(s => s.id === id); const s = spieler.find(s => s.id === id);
if (s) { s.questIdx = val !== '' ? parseInt(val) : null; saveSpieler(); renderSpieler(); } if (s) { s.questIdx = val !== '' ? parseInt(val) : null; await apiPut('/spieler/' + id, {quest_id: s.questIdx}); renderSpieler(); }
} }
function getInitial(name) { function getInitial(name) { return name.charAt(0).toUpperCase(); }
return name.charAt(0).toUpperCase();
}
function renderSpieler() { function renderSpieler() {
const list = document.getElementById('spielerList'); const list = document.getElementById('spielerList');
@@ -1605,8 +1609,7 @@ function renderSpieler() {
} else { } else {
list.innerHTML = spieler.map(s => { list.innerHTML = spieler.map(s => {
const questName = s.questIdx !== null ? quests[s.questIdx]?.name : null; const questName = s.questIdx !== null ? quests[s.questIdx]?.name : null;
return ` return `<div class="spieler-item">
<div class="spieler-item">
<div class="spieler-avatar">${getInitial(s.name)}</div> <div class="spieler-avatar">${getInitial(s.name)}</div>
<div class="spieler-info"> <div class="spieler-info">
<div class="spieler-name-display">${s.name}</div> <div class="spieler-name-display">${s.name}</div>
@@ -1617,15 +1620,13 @@ function renderSpieler() {
<option value=""> Keine Quest </option> <option value=""> Keine Quest </option>
${quests.map((q,i) => `<option value="${i}" ${s.questIdx===i?'selected':''}>${q.name}</option>`).join('')} ${quests.map((q,i) => `<option value="${i}" ${s.questIdx===i?'selected':''}>${q.name}</option>`).join('')}
</select> </select>
<select class="spieler-select" style="font-size:0.8rem; padding:0.25rem 0.4rem;" onchange="changeRolle(${s.id}, this.value)"> <select class="spieler-select" style="font-size:0.8rem;padding:0.25rem 0.4rem;" onchange="changeRolle(${s.id}, this.value)">
${Object.entries(rolleLabels).map(([k,v]) => `<option value="${k}" ${s.rolle===k?'selected':''}>${v}</option>`).join('')} ${Object.entries(rolleLabels).map(([k,v]) => `<option value="${k}" ${s.rolle===k?'selected':''}>${v}</option>`).join('')}
</select> </select>
<button class="btn-del" onclick="deleteSpieler(${s.id})">✕</button> <button class="btn-del" onclick="deleteSpieler(${s.id})">✕</button>
</div>`; </div>`;
}).join(''); }).join('');
} }
// Summary
const summary = document.getElementById('spielerSummary'); const summary = document.getElementById('spielerSummary');
if (summary) { if (summary) {
summary.style.display = spieler.length ? 'flex' : 'none'; summary.style.display = spieler.length ? 'flex' : 'none';
@@ -1636,60 +1637,6 @@ function renderSpieler() {
} }
} }
function saveSpieler() { // Start bei Page Load
try { localStorage.setItem('jga_spieler', JSON.stringify(spieler)); } catch(e) {} document.addEventListener('DOMContentLoaded', startApp);
}
function loadSpieler() {
try {
const s = JSON.parse(localStorage.getItem('jga_spieler') || 'null');
if (s) spieler = s;
} catch(e) {}
populateQuestSelect();
renderSpieler();
}
function saveState() {
try {
localStorage.setItem('jga_state', JSON.stringify({ itemCollected, drinks, questDone }));
} catch(e) {}
}
function loadState() {
try {
const s = JSON.parse(localStorage.getItem('jga_state') || 'null');
if (!s) return;
s.itemCollected?.forEach((v, i) => {
itemCollected[i] = v;
const slot = document.getElementById('inv-' + i);
if (slot && v) slot.classList.add('collected');
});
drinks = s.drinks || 0;
document.getElementById('drinkCount').textContent = drinks;
s.questDone?.forEach((v, i) => { questDone[i] = v; });
const notes = localStorage.getItem('jga_notes');
if (notes) document.getElementById('notes').value = notes;
} catch(e) {}
}
function saveNotes() {
try { localStorage.setItem('jga_notes', document.getElementById('notes').value); } catch(e) {}
}
function resetAll() {
if (!confirm('Wirklich alles zurücksetzen?')) return;
itemCollected.fill(false);
questDone.fill(false);
drinks = 0;
document.querySelectorAll('.item-slot').forEach(s => s.classList.remove('collected'));
document.getElementById('drinkCount').textContent = 0;
document.getElementById('notes').value = '';
spieler = [];
renderSpieler();
try { localStorage.clear(); } catch(e) {}
buildQuests();
updateQuestStatus();
}
</script> </script>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, '../')));
const DB_PATH = path.join(__dirname, 'data.db');
function getDb() {
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY, value TEXT
);
CREATE TABLE IF NOT EXISTS spieler (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT, rolle TEXT, quest_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS drinks_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount INTEGER, note TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
return db;
}
let db = getDb();
function initState() {
const row = db.prepare("SELECT value FROM state WHERE key = 'game'").get();
if (!row) {
const s = {
gameName: '', gameStarted: false, drinks: 0,
items: [false,false,false,false,false,false,false,false],
quests: [false,false,false,false,false,false,false,false],
timestamp: Date.now()
};
db.prepare('INSERT OR REPLACE INTO state (key,value) VALUES (?,?)').run('game', JSON.stringify(s));
}
}
initState();
// GET state
app.get('/api/game', (req, res) => {
initState();
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
res.json({
state: JSON.parse(sv.value),
spieler: db.prepare('SELECT * FROM spieler ORDER BY id').all(),
notes: db.prepare('SELECT * FROM notes ORDER BY created_at DESC').all(),
stats: db.prepare('SELECT SUM(amount) as total, COUNT(*) as entries FROM drinks_log').get() || {total:0,entries:0}
});
});
// Start
app.post('/api/game/start', (req, res) => {
initState();
const {name} = req.body;
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
const s = JSON.parse(sv.value);
s.gameName = name || 'Der letzte Auftrag';
s.gameStarted = true; s.timestamp = Date.now();
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
res.json({success:true, state:s});
});
// Drink
app.post('/api/game/drink', (req, res) => {
initState();
const amt = req.body.amount || 1;
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
const s = JSON.parse(sv.value);
s.drinks = Math.max(0, s.drinks + amt);
s.timestamp = Date.now();
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
db.prepare('INSERT INTO drinks_log (amount) VALUES (?)').run(amt);
res.json({success:true, drinks:s.drinks});
});
// Reset drink
app.post('/api/game/drink/reset', (req, res) => {
initState();
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
const s = JSON.parse(sv.value);
s.drinks = 0; s.timestamp = Date.now();
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
res.json({success:true, drinks:0});
});
// Toggle item
app.post('/api/game/toggle-item', (req, res) => {
initState();
const idx = parseInt(req.body.index);
if (idx<0||idx>7) return res.status(400).json({error:'Index 0-7'});
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
const s = JSON.parse(sv.value);
s.items[idx] = !s.items[idx];
s.timestamp = Date.now();
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
res.json({success:true, state:s, collected:s.items.filter(Boolean).length});
});
// Toggle quest
app.post('/api/game/toggle-quest', (req, res) => {
initState();
const idx = parseInt(req.body.index);
if (idx<0||idx>7) return res.status(400).json({error:'Index 0-7'});
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
const s = JSON.parse(sv.value);
s.quests[idx] = !s.quests[idx];
s.timestamp = Date.now();
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
res.json({success:true, state:s});
});
// Save full state
app.post('/api/game/save-state', (req, res) => {
initState();
const s = req.body; s.timestamp = Date.now();
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
res.json({success:true, state:s});
});
// Reset all
app.post('/api/game/reset', (req, res) => {
initState();
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
const s = JSON.parse(sv.value);
s.drinks=0; s.items=[false,false,false,false,false,false,false,false];
s.quests=[false,false,false,false,false,false,false,false];
s.gameStarted=false; s.gameName=''; s.timestamp=Date.now();
db.prepare("UPDATE state SET value=? WHERE key='game'").run(JSON.stringify(s));
db.prepare('DELETE FROM notes').run();
db.prepare('DELETE FROM drinks_log').run();
res.json({success:true, state:s});
});
// Spieler CRUD
app.get('/api/spieler', (req, res) => res.json(db.prepare('SELECT * FROM spieler ORDER BY id').all()));
app.post('/api/spieler', (req, res) => {
const {name, rolle, quest_id} = req.body;
const r = db.prepare('INSERT INTO spieler (name,rolle,quest_id) VALUES (?,?,?)').run(name, rolle, quest_id||null);
res.json({success:true, spieler:db.prepare('SELECT * FROM spieler WHERE id=?').get(r.lastInsertRowid)});
});
app.put('/api/spieler/:id', (req, res) => {
const {name, rolle, quest_id} = req.body;
db.prepare('UPDATE spieler SET name=COALESCE(?,name),rolle=COALESCE(?,rolle),quest_id=COALESCE(?,quest_id) WHERE id=?')
.run(name||null, rolle||null, quest_id||null, req.params.id);
res.json({success:true, spieler:db.prepare('SELECT * FROM spieler WHERE id=?').get(req.params.id)});
});
app.delete('/api/spieler/:id', (req, res) => {
db.prepare('DELETE FROM spieler WHERE id=?').run(req.params.id);
res.json({success:true});
});
// Notes
app.post('/api/notes', (req, res) => {
const {body} = req.body;
const r = db.prepare('INSERT INTO notes (body) VALUES (?)').run(body);
res.json({success:true, note:db.prepare('SELECT * FROM notes WHERE id=?').get(r.lastInsertRowid)});
});
// Stats
app.get('/api/game/stats', (req, res) => {
initState();
const sv = db.prepare("SELECT value FROM state WHERE key='game'").get();
const s = JSON.parse(sv.value);
res.json({
collected: s.items.filter(Boolean).length, total:8,
questsDone: s.quests.filter(Boolean).length, questsTotal:8,
drinks: s.drinks, gameName: s.gameName,
spielerCount: db.prepare('SELECT COUNT(*) as c FROM spieler').get().c,
spieler: db.prepare('SELECT * FROM spieler').all()
});
});
const PORT = process.env.PORT || 8082;
app.listen(PORT, '0.0.0.0', () => {
console.log(`🏰 Der letzte Auftrag server on port ${PORT}`);
});