This commit is contained in:
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user