Add TechTacular fifty-fifty joker
mindflux-ci / build (push) Successful in 34s

This commit is contained in:
AliceDarkest
2026-07-19 20:01:06 +02:00
parent 860b1ffba9
commit c5009d1718
11 changed files with 446 additions and 14 deletions
@@ -7,12 +7,16 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.HashSet;
import java.util.Set;
import jakarta.servlet.http.HttpSession;
/** Spieler-API für TechTacular: Board laden, Antwort prüfen, Runde abschließen. */
@RestController
@RequestMapping("/api/techtacular")
public class TechtacularApiController {
private static final String JOKER_USED = "techtacular.fiftyFiftyUsed";
private static final String HINTED_QUESTION_IDS = "techtacular.hintedQuestionIds";
private final TechtacularService techtacularService;
public TechtacularApiController(TechtacularService techtacularService) {
@@ -21,7 +25,10 @@ public class TechtacularApiController {
/** exclude = komma-separierte Frage-IDs der Vorrunde, die möglichst nicht erneut kommen sollen. */
@GetMapping("/board")
public TechtacularBoardView board(@RequestParam(required = false) String exclude) {
public TechtacularBoardView board(@RequestParam(required = false) String exclude, HttpSession session) {
// Ein neues Board beginnt immer eine neue Runde mit genau einem Joker.
session.removeAttribute(JOKER_USED);
session.removeAttribute(HINTED_QUESTION_IDS);
return techtacularService.buildBoard(parseIds(exclude));
}
@@ -40,12 +47,41 @@ public class TechtacularApiController {
}
@PostMapping("/answer")
public AnswerResult answer(@RequestBody AnswerRequest request) {
return techtacularService.checkAnswer(request.getQuestionId(), request.getAnswerId());
public AnswerResult answer(@RequestBody AnswerRequest request, HttpSession session) {
// Das Client-Flag dient der UI und dem Finish-Payload. Ob die Punkte halbiert
// werden, entscheidet jedoch allein der serverseitige Rundenzustand.
boolean validHint = hintedQuestionIds(session).contains(request.getQuestionId());
return techtacularService.checkAnswer(request.getQuestionId(), request.getAnswerId(), validHint);
}
/** Liefert zwei eliminierbare, garantiert falsche Antworten ohne die Loesung offenzulegen. */
@GetMapping("/hint")
public FiftyFiftyHint hint(@RequestParam Long questionId, HttpSession session) {
if (Boolean.TRUE.equals(session.getAttribute(JOKER_USED))) {
return new FiftyFiftyHint(java.util.List.of());
}
FiftyFiftyHint hint = techtacularService.fiftyFifty(questionId);
if (!hint.removeAnswerIds().isEmpty()) {
session.setAttribute(JOKER_USED, true);
Set<Long> hinted = new HashSet<>();
hinted.add(questionId);
session.setAttribute(HINTED_QUESTION_IDS, hinted);
}
return hint;
}
@PostMapping("/finish")
public TechtacularResult finish(@RequestBody FinishRequest request, Principal principal) {
return techtacularService.finish(principal.getName(), request);
public TechtacularResult finish(@RequestBody FinishRequest request, Principal principal, HttpSession session) {
TechtacularResult result = techtacularService.finish(principal.getName(), request, hintedQuestionIds(session));
session.removeAttribute(JOKER_USED);
session.removeAttribute(HINTED_QUESTION_IDS);
return result;
}
@SuppressWarnings("unchecked")
private Set<Long> hintedQuestionIds(HttpSession session) {
Object value = session.getAttribute(HINTED_QUESTION_IDS);
return value instanceof Set<?> ? (Set<Long>) value : Set.of();
}
}
@@ -4,10 +4,15 @@ package com.example.mindflux.dto;
public class AnswerRequest {
private Long questionId;
private Long answerId;
private boolean hintUsed;
public Long getQuestionId() { return questionId; }
public void setQuestionId(Long questionId) { this.questionId = questionId; }
public Long getAnswerId() { return answerId; }
public void setAnswerId(Long answerId) { this.answerId = answerId; }
/** Der Client zeigt damit an, dass fuer diese Antwort der 50:50-Joker aktiv war. */
public boolean isHintUsed() { return hintUsed; }
public void setHintUsed(boolean hintUsed) { this.hintUsed = hintUsed; }
}
@@ -0,0 +1,7 @@
package com.example.mindflux.dto;
import java.util.List;
/** Antwort des 50:50-Jokers. Die korrekte Antwort-ID wird niemals uebertragen. */
public record FiftyFiftyHint(List<Long> removeAnswerIds) {
}
@@ -85,6 +85,12 @@ public class TechtacularService {
/** Bewertet eine einzelne Antwort serverseitig (Frontend kennt die richtige Antwort vorher nicht). */
@Transactional(readOnly = true)
public AnswerResult checkAnswer(Long questionId, Long answerId) {
return checkAnswer(questionId, answerId, false);
}
/** Bewertet eine Antwort und reduziert nur bei einem gueltig eingesetzten Joker die Punkte. */
@Transactional(readOnly = true)
public AnswerResult checkAnswer(Long questionId, Long answerId, boolean hintUsed) {
TechtacularQuestion question = questionRepository.findById(questionId).orElse(null);
if (question == null) {
return new AnswerResult(false, null, "", 0);
@@ -92,12 +98,36 @@ public class TechtacularService {
TechtacularAnswer correct = question.correctAnswer();
Long correctId = correct != null ? correct.getId() : null;
boolean isCorrect = correctId != null && correctId.equals(answerId);
return new AnswerResult(isCorrect, correctId, question.getExplanation(), isCorrect ? question.getPoints() : 0);
int points = isCorrect ? question.getPoints() : 0;
if (hintUsed) {
points /= 2;
}
return new AnswerResult(isCorrect, correctId, question.getExplanation(), points);
}
/**
* Waehlt bis zu zwei falsche Antworten aus. Die korrekte Antwort wird dabei weder
* entfernt noch als separate Information an das Frontend uebermittelt.
*/
@Transactional(readOnly = true)
public FiftyFiftyHint fiftyFifty(Long questionId) {
TechtacularQuestion question = questionRepository.findById(questionId).orElse(null);
if (question == null) {
return new FiftyFiftyHint(List.of());
}
List<Long> removableIds = question.getAnswers().stream()
.filter(answer -> !answer.isCorrect())
.map(TechtacularAnswer::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(ArrayList::new));
Collections.shuffle(removableIds, random);
return new FiftyFiftyHint(removableIds.stream().limit(2).toList());
}
/** Schließt eine Runde ab: Score wird aus den tatsächlichen Antworten recomputed und gespeichert. */
@Transactional
public TechtacularResult finish(String username, FinishRequest request) {
public TechtacularResult finish(String username, FinishRequest request, Set<Long> hintedQuestionIds) {
int score = 0;
int maxScore = 0;
int correctCount = 0;
@@ -116,7 +146,11 @@ public class TechtacularService {
maxScore += question.getPoints();
TechtacularAnswer correct = question.correctAnswer();
if (correct != null && correct.getId().equals(answer.getAnswerId())) {
score += question.getPoints();
// hintUsed reist mit dem DTO fuer eine nachvollziehbare Client-UI;
// fuer die Wertung ist ausschliesslich die Session-Information massgeblich.
boolean jokerUsedForQuestion = hintedQuestionIds != null
&& hintedQuestionIds.contains(question.getId());
score += jokerUsedForQuestion ? question.getPoints() / 2 : question.getPoints();
correctCount++;
}
}
@@ -128,6 +162,12 @@ public class TechtacularService {
saved.getHighscore(), saved.isNewHighscore(), saved.getPlays());
}
/** Kompatibilitaetsvariante fuer Aufrufer ohne rundenbezogenen Joker-Zustand. */
@Transactional
public TechtacularResult finish(String username, FinishRequest request) {
return finish(username, request, Set.of());
}
// ---------- Admin ----------
/** Leichte Übersicht: Kategorien mit Fragenanzahl (eine GROUP-BY-Query für alle Counts). */
@@ -225,6 +225,17 @@ body.tt-body {
.tt-q-answers { display: grid; gap: 0.6rem; }
.tt-joker-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem 0.8rem;
margin: 0 0 0.9rem;
}
.tt-joker { padding: 0.42rem 0.75rem; font-size: 0.82rem; }
.tt-joker:disabled { cursor: default; opacity: 0.62; }
.tt-joker-note { color: var(--muted); font-size: 0.78rem; }
.tt-answer {
text-align: left;
padding: 0.75rem 1rem;
@@ -240,6 +251,13 @@ body.tt-body {
.tt-answer:disabled { cursor: default; }
.tt-answer.correct { border-color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); color: var(--success); }
.tt-answer.wrong { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); }
.tt-answer.eliminated {
border-color: var(--border);
background: color-mix(in srgb, var(--surface-2) 65%, transparent);
color: var(--muted);
opacity: 0.62;
text-decoration: line-through;
}
.tt-q-feedback { margin-top: 0.9rem; font-weight: 700; min-height: 1.2rem; }
.tt-q-feedback.correct { color: var(--success); }
+44 -4
View File
@@ -21,6 +21,8 @@
const qAnswers = document.getElementById('tt-q-answers');
const qFeedback = document.getElementById('tt-q-feedback');
const qNext = document.getElementById('tt-q-next');
const jokerBtn = document.getElementById('tt-fifty-fifty');
const jokerNote = document.getElementById('tt-joker-note');
const resultEl = document.getElementById('tt-result');
const resultScore = document.getElementById('tt-result-score');
@@ -38,7 +40,8 @@
let timerId = null;
let running = false;
let current = null; // gerade offene Kachel
const submitted = []; // {questionId, answerId} für /finish
let jokerUsed = false; // genau ein Joker pro Runde
const submitted = []; // {questionId, answerId, hintUsed} für /finish
function formatTime(s) {
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
@@ -100,6 +103,10 @@
qFeedback.textContent = '';
qFeedback.className = 'tt-q-feedback';
qNext.hidden = true;
jokerBtn.disabled = jokerUsed;
jokerNote.textContent = jokerUsed
? 'Der 50:50-Joker wurde in dieser Runde bereits eingesetzt.'
: 'Entfernt zwei falsche Antworten. Richtige Antwort: halbe Punkte.';
qAnswers.innerHTML = '';
state.data.answers.forEach((answer) => {
@@ -121,13 +128,13 @@
const buttons = Array.from(qAnswers.querySelectorAll('.tt-answer'));
buttons.forEach((b) => { b.disabled = true; });
submitted.push({ questionId: state.data.questionId, answerId });
submitted.push({ questionId: state.data.questionId, answerId, hintUsed: Boolean(state.hintUsed) });
try {
const res = await fetch('/api/techtacular/answer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ questionId: state.data.questionId, answerId })
body: JSON.stringify({ questionId: state.data.questionId, answerId, hintUsed: Boolean(state.hintUsed) })
});
const data = await res.json();
@@ -157,6 +164,38 @@
qNext.hidden = false;
}
async function useFiftyFifty() {
if (!current || current.answered || jokerUsed) return;
jokerBtn.disabled = true;
jokerNote.textContent = 'Joker wird eingesetzt ...';
try {
const res = await fetch(`/api/techtacular/hint?questionId=${encodeURIComponent(current.data.questionId)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const removedIds = Array.isArray(data.removeAnswerIds) ? data.removeAnswerIds : [];
if (removedIds.length === 0) {
jokerNote.textContent = 'Der Joker ist fuer diese Frage nicht verfuegbar.';
return;
}
const buttons = Array.from(qAnswers.querySelectorAll('.tt-answer'));
current.data.answers.forEach((answer, index) => {
if (removedIds.includes(answer.id)) {
buttons[index].disabled = true;
buttons[index].classList.add('eliminated');
}
});
current.hintUsed = true;
jokerUsed = true;
jokerNote.textContent = 'Zwei falsche Antworten entfernt. Richtige Antwort: halbe Punkte.';
} catch (err) {
jokerBtn.disabled = false;
jokerNote.textContent = 'Joker konnte nicht geladen werden.';
console.error(err);
}
}
function closeQuestion() {
if (!current) return;
const state = current;
@@ -225,7 +264,7 @@
async function newGame() {
stopTimer();
seconds = 0; score = 0; answeredCount = 0; current = null;
seconds = 0; score = 0; answeredCount = 0; current = null; jokerUsed = false;
submitted.length = 0;
timeEl.textContent = '0:00';
resultEl.hidden = true;
@@ -261,6 +300,7 @@
finishBtn.addEventListener('click', finishGame);
cancelBtn.addEventListener('click', cancelGame);
qNext.addEventListener('click', closeQuestion);
jokerBtn.addEventListener('click', useFiftyFifty);
loadStats();
newGame();
@@ -6,7 +6,7 @@
<title>TechTacular – SysnaptiK Neural</title>
<link rel="stylesheet" href="/css/theme.css?v=3">
<script src="/js/theme.js?v=4"></script>
<link rel="stylesheet" href="/css/techtacular.css?v=4">
<link rel="stylesheet" href="/css/techtacular.css?v=5">
</head>
<body class="tt-body">
@@ -50,6 +50,10 @@
</div>
<p class="tt-q-instruction">Antwort – finde die passende Frage:</p>
<h2 id="tt-q-text" class="tt-q-text"></h2>
<div class="tt-joker-row">
<button type="button" id="tt-fifty-fifty" class="tt-btn tt-btn-secondary tt-joker">50:50-Joker</button>
<span id="tt-joker-note" class="tt-joker-note">Entfernt zwei falsche Antworten. Richtige Antwort: halbe Punkte.</span>
</div>
<div id="tt-q-answers" class="tt-q-answers"></div>
<p id="tt-q-feedback" class="tt-q-feedback"></p>
<div class="tt-q-actions">
@@ -72,6 +76,6 @@
</div>
</div>
<script src="/js/techtacular.js?v=3" defer></script>
<script src="/js/techtacular.js?v=4" defer></script>
</body>
</html>
@@ -0,0 +1,43 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.FiftyFiftyHint;
import com.example.mindflux.service.TechtacularService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.List;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** Stellt sicher, dass der Joker-Endpoint nur die eliminierbaren IDs ausliefert. */
class TechtacularApiControllerTest {
private TechtacularService techtacularService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
techtacularService = mock(TechtacularService.class);
mockMvc = MockMvcBuilders.standaloneSetup(new TechtacularApiController(techtacularService)).build();
}
@Test
void hintEndpointReturnsOnlyTheRemovalIds() throws Exception {
when(techtacularService.fiftyFifty(42L)).thenReturn(new FiftyFiftyHint(List.of(7L, 11L)));
mockMvc.perform(get("/api/techtacular/hint").param("questionId", "42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.removeAnswerIds[0]").value(7))
.andExpect(jsonPath("$.removeAnswerIds[1]").value(11))
.andExpect(jsonPath("$.correctAnswerId").doesNotExist());
verify(techtacularService).fiftyFifty(42L);
}
}
@@ -0,0 +1,62 @@
package com.example.mindflux.service;
import com.example.mindflux.model.TechtacularAnswer;
import com.example.mindflux.model.TechtacularQuestion;
import com.example.mindflux.repository.TechtacularCategoryRepository;
import com.example.mindflux.repository.TechtacularQuestionRepository;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class TechtacularServiceTest {
@Test
void fiftyFiftyRemovesOnlyIncorrectAnswers() {
TechtacularQuestionRepository questions = mock(TechtacularQuestionRepository.class);
TechtacularQuestion question = new TechtacularQuestion("Hinweis", "", 400);
question.setId(42L);
TechtacularAnswer correct = answer(1L, true);
question.addAnswer(correct);
question.addAnswer(answer(2L, false));
question.addAnswer(answer(3L, false));
question.addAnswer(answer(4L, false));
when(questions.findById(42L)).thenReturn(Optional.of(question));
TechtacularService service = new TechtacularService(
mock(TechtacularCategoryRepository.class), questions, mock(GameScoreService.class));
var hint = service.fiftyFifty(42L);
assertEquals(2, hint.removeAnswerIds().size());
assertFalse(hint.removeAnswerIds().contains(correct.getId()));
assertTrue(Set.of(2L, 3L, 4L).containsAll(hint.removeAnswerIds()));
}
@Test
void correctAnswerWithJokerIsWorthHalfThePoints() {
TechtacularQuestionRepository questions = mock(TechtacularQuestionRepository.class);
TechtacularQuestion question = new TechtacularQuestion("Hinweis", "", 500);
question.setId(42L);
TechtacularAnswer correct = answer(1L, true);
question.addAnswer(correct);
when(questions.findById(42L)).thenReturn(Optional.of(question));
TechtacularService service = new TechtacularService(
mock(TechtacularCategoryRepository.class), questions, mock(GameScoreService.class));
assertEquals(250, service.checkAnswer(42L, 1L, true).getPoints());
}
private static TechtacularAnswer answer(Long id, boolean correct) {
TechtacularAnswer answer = new TechtacularAnswer("Antwort", correct, 0);
answer.setId(id);
return answer;
}
}