Syntax Saga: neues Spiel (server-autoritativ) + 34 Seed-Missionen

Drittes Mindflux-Spiel: interaktive IT-Geschichten, in denen man den
fehlenden Code (Schlüsselwörter, Operatoren, ganze Zeilen) in eine kleine
Story tippt. Freitext-Eingabe, drei wählbare Stufen (Junior/Professional/
Senior), mehrere Lücken je Codeblock und mehrere gültige Antworten je Lücke
(optional case-insensitive). Prüfung und Score ausschließlich serverseitig –
die Lösungen verlassen nie das Backend (Anti-Cheat wie TechTacular/MindMatch).
Fehler-Mechanik: bis zu 3 Versuche mit Hinweis, dann Auflösung; kein Game-Over.

Inhalte DB-gestützt und im Admin pflegbar (/admin/syntaxsaga); 34 Seed-
Missionen (Java/SQL/Bash/Git/HTML/Python) aus syntaxsaga/content.json.

- Entities SyntaxSagaMission/Gap/Solution + Flyway V7__syntax_saga.sql
- SyntaxSagaService (Story ohne Lösung / serverseitige Prüfung / Score-Recompute)
- API /api/syntaxsaga (CSRF-ausgenommen) + Admin-CRUD
- Frontend syntaxsaga.html/js/css (Prefix ss-, theme.css-Tokens)
- Dashboard-Kachel aktiv (vorher "coming soon")

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AliceDarkest
2026-06-30 12:13:37 +02:00
parent bbe24c67eb
commit 4599dd12b8
28 changed files with 2749 additions and 2 deletions
+434
View File
@@ -0,0 +1,434 @@
/* Syntax Saga – interaktive IT-Geschichten mit Code-Lücken.
Story kommt aus /api/syntaxsaga/story (ohne Lösung), Prüfung + Score serverseitig. */
(() => {
'use strict';
const GAME = 'syntax-saga';
const MAX_ATTEMPTS = 3;
const storyEl = document.getElementById('ss-story');
const diffBtns = Array.from(document.querySelectorAll('.ss-diff'));
const scoreEl = document.getElementById('ss-score');
const missionEl = document.getElementById('ss-mission');
const timeEl = document.getElementById('ss-time');
const bestEl = document.getElementById('ss-best');
const resultEl = document.getElementById('ss-result');
const resultStars = document.getElementById('ss-result-stars');
const resultScore = document.getElementById('ss-result-score');
const resultMax = document.getElementById('ss-result-max');
const resultDetail = document.getElementById('ss-result-detail');
const resultHigh = document.getElementById('ss-result-high');
const againBtn = document.getElementById('ss-again');
const chooseBtn = document.getElementById('ss-choose');
const DIFF_LABEL = { junior: 'Junior', professional: 'Professional', senior: 'Senior' };
let story = null; // StoryView vom Server
let missionIndex = 0;
let finishGaps = []; // gesammelte Lücken über alle Missionen -> /finish
let score = 0; // laufender Client-Schätzwert (spiegelt die Serverformel)
let seconds = 0;
let timerId = null;
let running = false;
let cur = null; // Laufzeitzustand der aktuellen Mission
let curInputs = {}; // position -> <input>
let curGaps = {}; // position -> Gap-Daten (points/hint/placeholder)
// ---------- Helfer ----------
function formatTime(s) {
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function startTimer() {
if (timerId) return;
timerId = setInterval(() => { seconds++; timeEl.textContent = formatTime(seconds); }, 1000);
}
function stopTimer() { clearInterval(timerId); timerId = null; }
/** Punkte aus Versuch (Erstversuch voll, danach weniger) – identisch zur Serverformel. */
function awarded(points, attempts) {
const a = Math.max(1, attempts);
const factor = a === 1 ? 1.0 : a === 2 ? 0.6 : 0.3;
return Math.round(points * factor);
}
function setIdle(message) {
storyEl.innerHTML = `<p class="ss-idle">${message}</p>`;
}
function updateHud() {
scoreEl.textContent = String(score);
if (story && story.missions.length) {
missionEl.textContent = `${Math.min(missionIndex + 1, story.missions.length)} / ${story.missions.length}`;
} else {
missionEl.textContent = '– / –';
}
}
// ---------- Code-Rendering ----------
/** Baut <code>-Inhalt: Textsegmente als Textknoten, {{n}} als Eingabefelder. */
function renderCode(codeEl, template) {
codeEl.textContent = '';
curInputs = {};
const regex = /\{\{(\d+)\}\}/g;
let last = 0;
let m;
while ((m = regex.exec(template)) !== null) {
if (m.index > last) {
codeEl.appendChild(document.createTextNode(template.slice(last, m.index)));
}
const pos = parseInt(m[1], 10);
const gap = curGaps[pos];
const input = document.createElement('input');
input.type = 'text';
input.className = 'ss-gap';
input.dataset.pos = String(pos);
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
input.setAttribute('autocorrect', 'off');
if (gap && gap.placeholder) input.placeholder = gap.placeholder;
input.size = Math.max(6, gap && gap.placeholder ? gap.placeholder.length : 8);
input.addEventListener('input', () => {
input.size = Math.max(4, input.value.length + 1);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); onCheck(false); }
});
curInputs[pos] = input;
codeEl.appendChild(input);
last = m.index + m[0].length;
}
if (last < template.length) {
codeEl.appendChild(document.createTextNode(template.slice(last)));
}
}
// ---------- Mission rendern ----------
function renderMission() {
const mission = story.missions[missionIndex];
cur = { mission, attempts: {}, revealed: {}, solved: {}, scored: {} };
curGaps = {};
(mission.gaps || []).forEach((g) => { curGaps[g.position] = g; });
storyEl.innerHTML = '';
const scene = document.createElement('div');
scene.className = 'ss-scene';
// Kopf
const head = document.createElement('div');
head.className = 'ss-scene-head';
const no = document.createElement('span');
no.className = 'ss-mission-no';
no.textContent = `Mission ${missionIndex + 1} / ${story.missions.length}`;
head.appendChild(no);
const diff = document.createElement('span');
diff.className = 'ss-badge ss-badge-diff';
diff.textContent = DIFF_LABEL[mission.difficulty] || mission.difficulty;
head.appendChild(diff);
if (mission.language) {
const lang = document.createElement('span');
lang.className = 'ss-badge ss-badge-lang';
lang.textContent = mission.language;
head.appendChild(lang);
}
scene.appendChild(head);
// Titel + Story
const title = document.createElement('h2');
title.className = 'ss-mission-title';
title.textContent = mission.title;
scene.appendChild(title);
if (mission.narrative) {
const nar = document.createElement('p');
nar.className = 'ss-narrative';
nar.textContent = mission.narrative;
scene.appendChild(nar);
}
if (mission.dialog) {
const dlg = document.createElement('p');
dlg.className = 'ss-dialog';
dlg.textContent = `„${mission.dialog}“`;
scene.appendChild(dlg);
}
// Codeblock mit Lücken
const pre = document.createElement('pre');
pre.className = 'ss-code';
const code = document.createElement('code');
renderCode(code, mission.codeTemplate || '');
pre.appendChild(code);
scene.appendChild(pre);
// Feedback
const feedback = document.createElement('p');
feedback.className = 'ss-feedback';
scene.appendChild(feedback);
// Erfolgstext (nach Lösen)
const success = document.createElement('p');
success.className = 'ss-success';
success.hidden = true;
scene.appendChild(success);
// Aktionen
const actions = document.createElement('div');
actions.className = 'ss-scene-actions';
const checkBtn = document.createElement('button');
checkBtn.type = 'button';
checkBtn.className = 'ss-btn';
checkBtn.textContent = 'Prüfen';
checkBtn.addEventListener('click', () => onCheck(false));
const revealBtn = document.createElement('button');
revealBtn.type = 'button';
revealBtn.className = 'ss-btn ss-btn-ghost';
revealBtn.textContent = 'Auflösung anzeigen';
revealBtn.hidden = true;
revealBtn.addEventListener('click', () => onCheck(true));
const nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'ss-btn';
nextBtn.textContent = missionIndex + 1 < story.missions.length ? 'Weiter' : 'Saga abschließen';
nextBtn.hidden = true;
nextBtn.addEventListener('click', nextMission);
actions.appendChild(checkBtn);
actions.appendChild(revealBtn);
actions.appendChild(nextBtn);
scene.appendChild(actions);
cur.els = { scene, feedback, success, checkBtn, revealBtn, nextBtn };
storyEl.appendChild(scene);
updateHud();
const first = Object.values(curInputs)[0];
if (first) first.focus();
}
// ---------- Prüfen / Auflösen ----------
async function onCheck(reveal) {
if (!running || !cur) return;
const gaps = cur.mission.gaps || [];
if (!reveal) {
gaps.forEach((g) => {
if (!cur.solved[g.position] && !cur.revealed[g.position]) {
cur.attempts[g.position] = (cur.attempts[g.position] || 0) + 1;
}
});
}
const answers = gaps.map((g) => ({
position: g.position,
text: (curInputs[g.position] && curInputs[g.position].value) || ''
}));
let data;
try {
const res = await fetch('/api/syntaxsaga/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ missionId: cur.mission.missionId, reveal, answers })
});
data = await res.json();
} catch (err) {
cur.els.feedback.textContent = 'Antwort konnte nicht geprüft werden.';
cur.els.feedback.className = 'ss-feedback wrong';
console.error(err);
return;
}
applyVerdicts(data, reveal);
refreshSceneState();
}
function applyVerdicts(data, reveal) {
(data.results || []).forEach((v) => {
const input = curInputs[v.position];
if (!input) return;
if (cur.solved[v.position] || cur.revealed[v.position]) return;
if (v.correct) {
input.classList.add('correct');
input.classList.remove('wrong');
input.disabled = true;
cur.solved[v.position] = true;
if (!cur.scored[v.position]) {
cur.scored[v.position] = true;
const gap = curGaps[v.position];
if (gap) { score += awarded(gap.points, cur.attempts[v.position] || 1); }
}
} else if (reveal && v.solution) {
input.value = v.solution;
input.classList.add('revealed');
input.classList.remove('wrong');
input.disabled = true;
cur.revealed[v.position] = true;
} else {
input.classList.add('wrong');
}
});
updateHud();
}
function refreshSceneState() {
const gaps = cur.mission.gaps || [];
const open = gaps.filter((g) => !cur.solved[g.position] && !cur.revealed[g.position]);
const fb = cur.els.feedback;
if (open.length === 0) {
// Mission gelöst (oder aufgelöst)
cur.els.checkBtn.hidden = true;
cur.els.revealBtn.hidden = true;
cur.els.nextBtn.hidden = false;
const anyRevealed = gaps.some((g) => cur.revealed[g.position]);
fb.textContent = anyRevealed ? 'Aufgelöst – weiter geht die Geschichte.' : 'Richtig! Sauber gelöst.';
fb.className = anyRevealed ? 'ss-feedback revealed' : 'ss-feedback correct';
if (cur.mission.successText) {
cur.els.success.textContent = cur.mission.successText;
cur.els.success.hidden = false;
}
cur.els.nextBtn.focus();
return;
}
// noch offene Lücken
const hints = open.map((g) => curGaps[g.position] && curGaps[g.position].hint).filter(Boolean);
fb.className = 'ss-feedback wrong';
fb.textContent = hints.length ? `Noch nicht ganz. Tipp: ${hints.join(' · ')}` : 'Noch nicht ganz – versuch es noch einmal.';
const exhausted = open.some((g) => (cur.attempts[g.position] || 0) >= MAX_ATTEMPTS);
cur.els.revealBtn.hidden = !exhausted;
if (exhausted) {
fb.textContent = hints.length
? `Versuche aufgebraucht. Tipp: ${hints.join(' · ')} – oder zeig dir die Auflösung.`
: 'Versuche aufgebraucht – zeig dir die Auflösung.';
}
}
// ---------- Navigation ----------
function collectCurrentMission() {
(cur.mission.gaps || []).forEach((g) => {
finishGaps.push({
missionId: cur.mission.missionId,
position: g.position,
text: (curInputs[g.position] && curInputs[g.position].value) || '',
attempts: cur.attempts[g.position] || 1,
revealed: !!cur.revealed[g.position]
});
});
}
function nextMission() {
collectCurrentMission();
missionIndex++;
if (missionIndex < story.missions.length) {
renderMission();
} else {
finishRun();
}
}
async function finishRun() {
running = false;
stopTimer();
try {
const res = await fetch('/api/syntaxsaga/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
difficulty: story.difficulty,
durationSeconds: seconds,
gaps: finishGaps
})
});
const data = await res.json();
score = data.score;
updateHud();
const stars = Math.max(0, Math.min(3, data.stars));
resultStars.textContent = '★'.repeat(stars) + '☆'.repeat(3 - stars);
resultScore.textContent = String(data.score);
resultMax.textContent = String(data.maxScore);
resultDetail.textContent =
`${data.solvedGaps} von ${data.totalGaps} Lücken auf Anhieb · ${formatTime(seconds)}`;
resultHigh.hidden = !data.newHighscore;
bestEl.textContent = String(data.highscore);
resultEl.hidden = false;
} catch (err) {
console.error('Saga konnte nicht gespeichert werden:', err);
}
}
// ---------- Start / Stats ----------
async function loadStats() {
try {
const res = await fetch(`/api/games/${GAME}/stats`);
if (!res.ok) return;
const stats = await res.json();
bestEl.textContent = stats.highscore > 0 ? String(stats.highscore) : '–';
} catch (err) { /* egal */ }
}
async function startRun(difficulty) {
resultEl.hidden = true;
stopTimer();
seconds = 0; score = 0; missionIndex = 0; finishGaps = [];
timeEl.textContent = '0:00';
diffBtns.forEach((b) => b.classList.toggle('active', b.dataset.diff === difficulty));
setIdle('Lade Mission …');
try {
const res = await fetch('/api/syntaxsaga/story?difficulty=' + encodeURIComponent(difficulty));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
story = await res.json();
} catch (err) {
console.error('Story konnte nicht geladen werden:', err);
setIdle('Story konnte nicht geladen werden.');
return;
}
if (!story.missions || !story.missions.length) {
setIdle('Für diese Stufe gibt es noch keine Missionen. Ein Admin kann sie unter „Syntax Saga verwalten“ anlegen.');
running = false;
updateHud();
return;
}
running = true;
renderMission();
startTimer();
}
diffBtns.forEach((btn) => {
btn.addEventListener('click', () => {
if (running && finishGaps.length === 0 && missionIndex === 0 && cur &&
Object.keys(cur.solved).length === 0) {
// noch nichts gespielt -> einfach wechseln
} else if (running && !window.confirm('Aktuelle Saga abbrechen und neu starten?')) {
return;
}
startRun(btn.dataset.diff);
});
});
againBtn.addEventListener('click', () => startRun(story ? story.difficulty : 'junior'));
chooseBtn.addEventListener('click', () => {
resultEl.hidden = true;
running = false;
story = null;
diffBtns.forEach((b) => b.classList.remove('active'));
setIdle('Wähle oben eine Stufe, um deine Saga zu starten.');
updateHud();
});
loadStats();
setIdle('Wähle oben eine Stufe – Junior, Professional oder Senior – um deine Saga zu starten.');
})();